diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40273a54ec91..b78951652254 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,9 @@ jobs: - name: Validate TUI npm package artifact transfer run: python3 tests/test_tui_npm_package_artifact.py + - name: Validate TUI package launchers + run: python3 tests/test_tui_package_launchers.py + - name: Validate Claude launch environment policy generation run: python3 scripts/generate-claude-launch-environment-policy.py --check diff --git a/.github/workflows/cmux-tui-build-package.yml b/.github/workflows/cmux-tui-build-package.yml index ebcabc745571..e3f8dab58ca6 100644 --- a/.github/workflows/cmux-tui-build-package.yml +++ b/.github/workflows/cmux-tui-build-package.yml @@ -215,6 +215,13 @@ jobs: shell: bash run: rustup target add ${{ matrix.target }} + - name: Configure macOS deployment target + if: runner.os == 'macOS' + shell: bash + run: | + target="$(tr -d '[:space:]' < cmux-tui/dist/macos-deployment-target.txt)" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "$target" >> "$GITHUB_ENV" + - name: Install cargo-zigbuild if: matrix.cross == true && runner.os == 'Linux' shell: bash @@ -223,8 +230,8 @@ jobs: - name: Build cmux-tui (native) if: matrix.cross == false env: - CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }} PACKAGE_NPM: ${{ inputs.package_npm }} + VERSION: ${{ inputs.version }} working-directory: cmux-tui shell: bash run: | @@ -233,9 +240,10 @@ jobs: unset CMUX_GHOSTTY_SRC CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)" CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)" + CMUX_TUI_DISTRIBUTION_VERSION="$VERSION" export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION if [[ "$PACKAGE_NPM" == "true" ]]; then - CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION" + CMUX_TUI_NPM_BOOTSTRAP_VERSION="$VERSION" export CMUX_TUI_NPM_BOOTSTRAP_VERSION fi cargo build -p cmux-tui --bin cmux-tui --bin cmux-tui-hook --release --locked --target ${{ matrix.build_target }} @@ -244,8 +252,8 @@ jobs: - name: Build cmux-tui (Linux cross) if: matrix.cross == true && runner.os == 'Linux' env: - CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }} PACKAGE_NPM: ${{ inputs.package_npm }} + VERSION: ${{ inputs.version }} working-directory: cmux-tui shell: bash run: | @@ -254,13 +262,19 @@ jobs: unset CMUX_GHOSTTY_SRC CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)" CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)" + CMUX_TUI_DISTRIBUTION_VERSION="$VERSION" export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION if [[ "$PACKAGE_NPM" == "true" ]]; then - CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION" + CMUX_TUI_NPM_BOOTSTRAP_VERSION="$VERSION" export CMUX_TUI_NPM_BOOTSTRAP_VERSION fi - cargo zigbuild -p cmux-tui --bin cmux-tui --bin cmux-tui-hook --release --locked --target ${{ matrix.build_target }} - cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }} + if [[ "$RUNNER_OS" == macOS ]]; then + cargo build -p cmux-tui --bin cmux-tui --bin cmux-tui-hook --release --locked --target ${{ matrix.build_target }} + cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }} + else + cargo zigbuild -p cmux-tui --bin cmux-tui --bin cmux-tui-hook --release --locked --target ${{ matrix.build_target }} + cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }} + fi - name: Build cmux-tui (macOS cross) if: matrix.cross == true && runner.os == 'macOS' @@ -300,6 +314,27 @@ jobs: fi ls -la dist + - name: Verify macOS deployment target + if: runner.os == 'macOS' + shell: bash + run: | + binary="dist/cmux-tui-${{ matrix.target }}${{ matrix.ext }}" + expected="$(tr -d '[:space:]' < cmux-tui/dist/macos-deployment-target.txt)" + actual="$(xcrun vtool -show-build "$binary" | awk '$1 == "minos" { print $2; exit }')" + test "$actual" = "$expected" + + - name: Verify native binary identity + if: matrix.cross == false + shell: bash + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + python3 cmux-tui/dist/scripts/verify_artifact_identity.py \ + --binary "dist/cmux-tui-${{ matrix.target }}${{ matrix.ext }}" \ + --version "$EXPECTED_VERSION" \ + --build-commit "$(git rev-parse HEAD)" \ + --ghostty-commit "$(git -C ghostty rev-parse HEAD)" + - name: Verify non-npm binaries disable SSH auto-install if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-musl' && inputs.package_npm == false shell: bash @@ -461,8 +496,8 @@ jobs: - name: Build libghostty-vt + cmux-tui (Windows GNU) env: - CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }} PACKAGE_NPM: ${{ inputs.package_npm }} + VERSION: ${{ inputs.version }} shell: bash run: | # Keep the manual Zig build and Cargo's build script on the same @@ -470,9 +505,10 @@ jobs: unset CMUX_GHOSTTY_SRC CMUX_TUI_BUILD_COMMIT="$(git rev-parse HEAD)" CMUX_TUI_GHOSTTY_COMMIT="$(git -C ghostty rev-parse HEAD)" + CMUX_TUI_DISTRIBUTION_VERSION="$VERSION" export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION if [[ "$PACKAGE_NPM" == "true" ]]; then - CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION" + CMUX_TUI_NPM_BOOTSTRAP_VERSION="$VERSION" export CMUX_TUI_NPM_BOOTSTRAP_VERSION fi cd ghostty @@ -542,6 +578,7 @@ jobs: permissions: contents: read env: + BINARY_VERSION: ${{ inputs.version }} NPM_VERSION: ${{ inputs.version }} PYPI_VERSION: ${{ inputs.pypi_version != '' && inputs.pypi_version || inputs.version }} steps: @@ -582,6 +619,15 @@ jobs: name: cmux-tui-aarch64-unknown-linux-musl path: dist/binaries + - name: Verify downloaded binary identity + run: | + chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-gnu + python3 cmux-tui/dist/scripts/verify_artifact_identity.py \ + --binary dist/binaries/cmux-tui-x86_64-unknown-linux-gnu \ + --version "$NPM_VERSION" \ + --build-commit "$(git rev-parse HEAD)" \ + --ghostty-commit "$(git rev-parse HEAD:ghostty)" + - name: Build npm package directories if: inputs.package_npm run: | @@ -593,6 +639,7 @@ jobs: - name: Build PyPI wheels if: inputs.package_pypi run: | + python3 cmux-tui/dist/scripts/test_package_pypi.py python3 cmux-tui/dist/scripts/package_pypi.py \ --binaries-dir dist/binaries \ --version "$PYPI_VERSION" \ @@ -638,6 +685,14 @@ jobs: chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-musl dist/binaries/cmux-tui-x86_64-unknown-linux-musl --version >/tmp/cmux-tui-version.txt 2>&1 || \ dist/binaries/cmux-tui-x86_64-unknown-linux-musl --help >/tmp/cmux-tui-version.txt 2>&1 + mkdir -p dist/npm-smoke/node_modules + cp -R dist/npm-packages/cmux dist/npm-smoke/node_modules/cmux + cp -R dist/npm-packages/cmux-tui-linux-x64 dist/npm-smoke/node_modules/cmux-tui-linux-x64 + python3 cmux-tui/dist/scripts/verify_artifact_identity.py \ + --binary dist/npm-smoke/node_modules/cmux/bin/cmux.js \ + --version "$NPM_VERSION" \ + --build-commit "$(git rev-parse HEAD)" \ + --ghostty-commit "$(git rev-parse HEAD:ghostty)" CMUX_TUI_PROBE="$(dist/binaries/cmux-tui-x86_64-unknown-linux-musl remote-probe --json)" CMUX_TUI_EXPECTED_BUILD_IDENTITY="$(git rev-parse HEAD)" export CMUX_TUI_PROBE CMUX_TUI_EXPECTED_BUILD_IDENTITY @@ -694,8 +749,20 @@ jobs: dist/binaries/cmux-tui-x86_64-unknown-linux-musl --help >/tmp/cmux-tui-version.txt 2>&1 python3 -m venv /tmp/cmux-tui-wheel-smoke /tmp/cmux-tui-wheel-smoke/bin/python -m pip install --no-index --find-links dist/pypi-wheels cmux=="$PYPI_VERSION" - /tmp/cmux-tui-wheel-smoke/bin/cmux --help >/tmp/cmux-help.txt - grep -i usage /tmp/cmux-help.txt + /tmp/cmux-tui-wheel-smoke/bin/python - <<'PY' + import importlib.metadata + import os + + expected = os.environ["PYPI_VERSION"] + actual = importlib.metadata.version("cmux") + if actual != expected: + raise SystemExit(f"installed wheel version {actual!r} != {expected!r}") + PY + python3 cmux-tui/dist/scripts/verify_artifact_identity.py \ + --binary /tmp/cmux-tui-wheel-smoke/bin/cmux \ + --version "$BINARY_VERSION" \ + --build-commit "$(git rev-parse HEAD)" \ + --ghostty-commit "$(git rev-parse HEAD:ghostty)" - name: Upload npm package directories if: inputs.package_npm diff --git a/.github/workflows/cmux-tui.yml b/.github/workflows/cmux-tui.yml index c7e4825d3ebc..c619605e44a7 100644 --- a/.github/workflows/cmux-tui.yml +++ b/.github/workflows/cmux-tui.yml @@ -24,6 +24,11 @@ on: description: "Unique caller token used to find this run" required: true type: string + use_blacksmith_macos: + description: "Run the macOS test matrix on Blacksmith" + required: false + type: boolean + default: false concurrency: # Every caller waits for one exact commit. A newer request must not cancel it. @@ -316,16 +321,12 @@ jobs: test: name: test (${{ matrix.os }}) needs: validate-inputs - runs-on: ${{ matrix.runner }} + runs-on: ${{ matrix.os == 'macos' && (inputs.use_blacksmith_macos && 'blacksmith-6vcpu-macos-15' || (vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15')) || (vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404') }} timeout-minutes: 40 strategy: fail-fast: false matrix: - include: - - os: macos - runner: blacksmith-6vcpu-macos-15 - - os: linux - runner: blacksmith-4vcpu-ubuntu-2404 + os: [macos, linux] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift index c0aa51bac652..c7390fc88739 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift @@ -26,6 +26,12 @@ public protocol TerminalSurfacePaneHosting: NSView { /// indicator text. func syncKeyStateIndicator(text: String?) + /// Shows a permanent failure message when the native terminal cannot start. + func showRuntimeSurfaceCreationFailure(message: String) + + /// Clears a prior creation failure after the native terminal starts. + func clearRuntimeSurfaceCreationFailure() + /// Notifies the pane that explicit terminal input is about to be sent. func terminalSurfaceDidReceiveExplicitInput() diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeActiveTeardown.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeActiveTeardown.swift new file mode 100644 index 000000000000..8424a0a01fea --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeActiveTeardown.swift @@ -0,0 +1,7 @@ +import Foundation + +internal struct TerminalSurfaceRuntimeActiveTeardown: Sendable { + let ticketID: UUID + let task: Task + let watchdogTask: Task? +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipAdmission.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipAdmission.swift new file mode 100644 index 000000000000..49ce6b6819a3 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipAdmission.swift @@ -0,0 +1,554 @@ +import Foundation +internal import os + +typealias TerminalSurfaceRuntimeOwnershipRecovery = + @MainActor @Sendable (TerminalSurfaceRuntimeOwnershipReservation) -> Void +typealias TerminalSurfaceRuntimeOwnershipRecoveryFailure = + @MainActor @Sendable () -> Void + +/// Synchronous, bounded admission for native-surface ownership. +/// +/// Creation, public teardown, and deinit cannot suspend while transferring a +/// native pointer, so this ledger uses one short unfair-lock critical section. +/// An actor would make creation and deinit await while they hold sole pointer +/// custody, so the lock is the explicit synchronous isolation boundary. +/// Each ownership reservation also holds one submission-ingress slot until the +/// teardown submission is dequeued or the unused ownership is released. +/// Native work never runs under the lock. A fully stalled close pool degrades +/// admission until a worker returns, preventing repeated create/close cycles +/// from growing an unbounded retained teardown backlog. +final class TerminalSurfaceRuntimeOwnershipAdmission: @unchecked Sendable { + private let maximumOwnerCount: Int + private let recoveryRescanScheduler: + TerminalSurfaceRuntimeOwnershipRecoveryRescanScheduler + private let state = OSAllocatedUnfairLock( + initialState: TerminalSurfaceRuntimeOwnershipAdmissionState() + ) + + init( + maximumOwnerCount: Int, + recoveryRescanScheduler: + TerminalSurfaceRuntimeOwnershipRecoveryRescanScheduler? = nil + ) { + precondition( + maximumOwnerCount >= TerminalSurfaceRuntimeTeardownCoordinator + .maximumConcurrentCloseTeardownCount, + "native surface ownership must cover every close worker" + ) + self.maximumOwnerCount = maximumOwnerCount + self.recoveryRescanScheduler = recoveryRescanScheduler + ?? TerminalSurfaceRuntimeOwnershipRecoveryRescanScheduler( + maximumEntryCount: maximumOwnerCount + ) + } + + func reserve() -> TerminalSurfaceRuntimeOwnershipReservation? { + state.withLock { state in + guard !state.closeTeardownDegraded, + !state.closeTeardownAllStalled, + state.reservationIDs.count < maximumOwnerCount, + state.ingressReservationIDs.count < maximumOwnerCount else { + return nil + } + return reserveOwnershipAndIngress(in: &state) + } + } + + func reserve( + recoveryID: UUID, + onRecovery: @escaping TerminalSurfaceRuntimeOwnershipRecovery, + onFailure: @escaping TerminalSurfaceRuntimeOwnershipRecoveryFailure = {}, + capacityReservation: + TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation? = nil + ) -> TerminalSurfaceRuntimeOwnershipRecoveryAdmissionResult { + let output: ( + result: TerminalSurfaceRuntimeOwnershipRecoveryAdmissionResult, + requestRescan: Bool + ) = recoveryRescanScheduler.withOverflowQueueFence { + hasPendingOverflow in + state.withLock { state in + let claimedCapacity: Bool + if let capacityReservation { + guard state.recoveryCapacityReservationIDs.remove( + capacityReservation.id + ) != nil else { + return (.rejected, false) + } + claimedCapacity = true + } else { + claimedCapacity = false + } + guard !state.closeTeardownAllStalled else { + if state.recoveryEntriesByID[recoveryID] != nil { + state.recoveryEntriesByID[recoveryID]?.action = onRecovery + state.recoveryEntriesByID[recoveryID]?.failure = onFailure + return (.deferred, claimedCapacity) + } + return (.closeTeardownStalled, claimedCapacity) + } + if hasPendingOverflow, + capacityReservation == nil, + state.recoveryEntriesByID[recoveryID] == nil { + return (.rejected, false) + } + if !state.closeTeardownDegraded, + state.recoveryHeadID == nil, + !state.recoveryGrantIsScheduled, + state.reservationIDs.count < maximumOwnerCount, + state.ingressReservationIDs.count < maximumOwnerCount + { + _ = removeRecoveryAction(recoveryID, from: &state) + return ( + .reserved(reserveOwnershipAndIngress(in: &state)), + takeRecoveryRescanRequest(from: &state) || claimedCapacity + ) + } + if state.recoveryEntriesByID[recoveryID] != nil { + state.recoveryEntriesByID[recoveryID]?.action = onRecovery + state.recoveryEntriesByID[recoveryID]?.failure = onFailure + return (.deferred, claimedCapacity) + } + guard claimedCapacity || recoveryCapacityIsOpen(in: state) else { + return (.rejected, false) + } + enqueueRecoveryAction( + onRecovery, + onFailure: onFailure, + id: recoveryID, + in: &state + ) + return (.deferred, false) + } + } + if output.requestRescan { + recoveryRescanScheduler.requestRescan() + } + return output.result + } + + func claimRecoveryCapacity() + -> TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation? { + state.withLock { state in + guard overflowRecoveryCapacityIsOpen(in: state) else { + return nil + } + let reservation = + TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation() + state.recoveryCapacityReservationIDs.insert(reservation.id) + return reservation + } + } + + func recoveryCapacityIsOpen() -> Bool { + state.withLock { overflowRecoveryCapacityIsOpen(in: $0) } + } + + func releaseRecoveryCapacity( + _ reservation: + TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation + ) { + let released = state.withLock { state in + state.recoveryCapacityReservationIDs.remove(reservation.id) != nil + } + if released { + recoveryRescanScheduler.requestRescan() + } + } + + func contains(_ reservation: TerminalSurfaceRuntimeOwnershipReservation) -> Bool { + state.withLock { $0.reservationIDs.contains(reservation.id) } + } + + func claimIngressReservation( + for reservation: TerminalSurfaceRuntimeOwnershipReservation + ) -> TerminalSurfaceRuntimeTeardownIngressReservation? { + state.withLock { state in + guard state.reservationIDs.contains(reservation.id), + state.unclaimedOwnershipIngressReservationIDs.remove( + reservation.id + ) != nil else { + return nil + } + return TerminalSurfaceRuntimeTeardownIngressReservation( + id: reservation.id + ) + } + } + + func reserveControlIngress() + -> TerminalSurfaceRuntimeTeardownIngressReservation? { + let result: ( + TerminalSurfaceRuntimeTeardownIngressReservation?, + TerminalSurfaceRuntimeOwnershipRecoveryGrant?, + Bool + ) = state.withLock { state in + if let recoveryGrant = takeNextRecoveryGrant(from: &state) { + return ( + nil, + recoveryGrant, + takeRecoveryRescanRequest(from: &state) + ) + } + guard state.ingressReservationIDs.count < maximumOwnerCount else { + return (nil, nil, false) + } + let reservation = TerminalSurfaceRuntimeTeardownIngressReservation() + state.ingressReservationIDs.insert(reservation.id) + return (reservation, nil, false) + } + schedule(result.1) + if result.2 { + recoveryRescanScheduler.requestRescan() + } + return result.0 + } + + func releaseIngress( + _ reservation: TerminalSurfaceRuntimeTeardownIngressReservation + ) { + let output = state.withLock { state in + _ = state.ingressReservationIDs.remove(reservation.id) + _ = state.unclaimedOwnershipIngressReservationIDs.remove( + reservation.id + ) + let grant = takeNextRecoveryGrant(from: &state) + return (grant, takeRecoveryRescanRequest(from: &state)) + } + schedule(output.0) + if output.1 { + recoveryRescanScheduler.requestRescan() + } + } + + func releaseFailedSubmission( + ownership reservation: TerminalSurfaceRuntimeOwnershipReservation, + ingress ingressReservation: + TerminalSurfaceRuntimeTeardownIngressReservation + ) { + let output = state.withLock { state in + _ = state.reservationIDs.remove(reservation.id) + _ = state.ingressReservationIDs.remove(ingressReservation.id) + _ = state.unclaimedOwnershipIngressReservationIDs.remove( + reservation.id + ) + let grant = takeNextRecoveryGrant(from: &state) + return (grant, takeRecoveryRescanRequest(from: &state)) + } + schedule(output.0) + if output.1 { + recoveryRescanScheduler.requestRescan() + } + } + + func release(_ reservation: TerminalSurfaceRuntimeOwnershipReservation) { + let output = state.withLock { state in + _ = state.reservationIDs.remove(reservation.id) + if state.unclaimedOwnershipIngressReservationIDs.remove( + reservation.id + ) != nil { + _ = state.ingressReservationIDs.remove(reservation.id) + } + let grant = takeNextRecoveryGrant(from: &state) + return (grant, takeRecoveryRescanRequest(from: &state)) + } + schedule(output.0) + if output.1 { + recoveryRescanScheduler.requestRescan() + } + } + + func setCloseTeardownDegraded(_ degraded: Bool) { + let output = state.withLock { state in + state.closeTeardownDegraded = degraded + let grant = takeNextRecoveryGrant(from: &state) + return (grant, takeRecoveryRescanRequest(from: &state)) + } + schedule(output.0) + if output.1 { + recoveryRescanScheduler.requestRescan() + } + } + + func failRecoveriesForAllStalledCloseTeardowns() + -> [TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery] { + state.withLock { state in + state.closeTeardownAllStalled = true + var failures: [ + TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery + ] = [] + var recoveryID = state.recoveryHeadID + while let currentID = recoveryID, + let entry = state.recoveryEntriesByID[currentID] { + recoveryID = entry.nextID + guard entry.pendingFailureDeliveryID == nil else { continue } + let deliveryID = UUID() + let delivery = + TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery( + recoveryID: currentID, + deliveryID: deliveryID, + failure: { [self] in + deliverStalledCloseRecoveryFailure( + recoveryID: currentID, + deliveryID: deliveryID + ) + } + ) + state.recoveryEntriesByID[currentID]? + .pendingFailureDeliveryID = delivery.deliveryID + state.pendingRecoveryFailureDeliveryIDs.insert( + delivery.deliveryID + ) + failures.append(delivery) + } + precondition( + state.pendingRecoveryFailureDeliveryIDs.count + <= maximumOwnerCount, + "stalled recovery failures must remain bounded" + ) + state.recoveryRescanRequested = false + return failures + } + } + + @MainActor + private func deliverStalledCloseRecoveryFailure( + recoveryID: UUID, + deliveryID: UUID + ) { + let failure: TerminalSurfaceRuntimeOwnershipRecoveryFailure? = + state.withLock { state in + guard let entry = state.recoveryEntriesByID[recoveryID], + entry.pendingFailureDeliveryID == deliveryID else { + return nil + } + return entry.failure + } + failure?() + } + + func completeStalledCloseRecoveryFailures( + _ completedDeliveries: [ + TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery + ] + ) { + precondition(!completedDeliveries.isEmpty) + let output = state.withLock { state in + let capacityWasOpen = recoveryCapacityIsOpen(in: state) + for delivery in completedDeliveries { + guard state.pendingRecoveryFailureDeliveryIDs.remove( + delivery.deliveryID + ) != nil else { continue } + if state.recoveryEntriesByID[delivery.recoveryID]? + .pendingFailureDeliveryID == delivery.deliveryID { + state.recoveryEntriesByID[delivery.recoveryID]? + .pendingFailureDeliveryID = nil + } + } + let recoveryCapacityOpened = + !capacityWasOpen && recoveryCapacityIsOpen(in: state) + let grant = takeNextRecoveryGrant(from: &state) + return ( + grant, + takeRecoveryRescanRequest(from: &state) + || recoveryCapacityOpened + ) + } + schedule(output.0) + if output.1 { + recoveryRescanScheduler.requestRescan() + } + } + + func ownsStalledCloseFailure( + _ delivery: TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery + ) -> Bool { + state.withLock { state in + state.pendingRecoveryFailureDeliveryIDs.contains( + delivery.deliveryID + ) + && state.recoveryEntriesByID[delivery.recoveryID]? + .pendingFailureDeliveryID == delivery.deliveryID + } + } + + func clearAllStalledCloseTeardowns() { + _ = clearAllStalledCloseTeardownsIfNeeded() + } + + func clearAllStalledCloseTeardownsIfNeeded() -> Bool { + let output: (TerminalSurfaceRuntimeOwnershipRecoveryGrant?, Bool) + output = state.withLock { state in + guard state.closeTeardownAllStalled else { + return (nil, false) + } + state.closeTeardownAllStalled = false + for recoveryID in Array(state.recoveryEntriesByID.keys) { + guard let deliveryID = state.recoveryEntriesByID[recoveryID]? + .pendingFailureDeliveryID else { continue } + state.recoveryEntriesByID[recoveryID]? + .pendingFailureDeliveryID = nil + state.pendingRecoveryFailureDeliveryIDs.remove(deliveryID) + } + let grant = takeNextRecoveryGrant(from: &state) + _ = takeRecoveryRescanRequest(from: &state) + return (grant, true) + } + schedule(output.0) + if output.1 { + recoveryRescanScheduler.requestRescan() + } + return output.1 + } + + func cancelRecovery(_ recoveryID: UUID) { + let requestRescan = state.withLock { state in + _ = removeRecoveryAction(recoveryID, from: &state) + return takeRecoveryRescanRequest(from: &state) + } + if requestRescan { + recoveryRescanScheduler.requestRescan() + } + } + + private func removeRecoveryAction( + _ recoveryID: UUID, + from state: inout TerminalSurfaceRuntimeOwnershipAdmissionState + ) -> TerminalSurfaceRuntimeOwnershipRecovery? { + guard let entry = state.recoveryEntriesByID.removeValue( + forKey: recoveryID + ) else { + return nil + } + state.recoveryRescanRequested = true + if let previousID = entry.previousID { + state.recoveryEntriesByID[previousID]?.nextID = entry.nextID + } else { + state.recoveryHeadID = entry.nextID + } + if let nextID = entry.nextID { + state.recoveryEntriesByID[nextID]?.previousID = entry.previousID + } else { + state.recoveryTailID = entry.previousID + } + return entry.action + } + + private func enqueueRecoveryAction( + _ action: @escaping TerminalSurfaceRuntimeOwnershipRecovery, + onFailure: @escaping TerminalSurfaceRuntimeOwnershipRecoveryFailure, + id recoveryID: UUID, + in state: inout TerminalSurfaceRuntimeOwnershipAdmissionState + ) { + let previousID = state.recoveryTailID + state.recoveryEntriesByID[recoveryID] = + TerminalSurfaceRuntimeOwnershipRecoveryEntry( + action: action, + failure: onFailure, + previousID: previousID, + nextID: nil + ) + if let previousID { + state.recoveryEntriesByID[previousID]?.nextID = recoveryID + } else { + state.recoveryHeadID = recoveryID + } + state.recoveryTailID = recoveryID + } + + private func recoveryCapacityIsOpen( + in state: TerminalSurfaceRuntimeOwnershipAdmissionState + ) -> Bool { + state.recoveryEntriesByID.count + + state.recoveryCapacityReservationIDs.count + + state.pendingRecoveryFailureDeliveryIDs.count + < maximumOwnerCount + } + + private func overflowRecoveryCapacityIsOpen( + in state: TerminalSurfaceRuntimeOwnershipAdmissionState + ) -> Bool { + !state.closeTeardownAllStalled + && state.recoveryHeadID == nil + && !state.recoveryGrantIsScheduled + && recoveryCapacityIsOpen(in: state) + } + + private func takeRecoveryRescanRequest( + from state: inout TerminalSurfaceRuntimeOwnershipAdmissionState + ) -> Bool { + let requested = state.recoveryRescanRequested + state.recoveryRescanRequested = false + return requested + } + + private func reserveOwnershipAndIngress( + in state: inout TerminalSurfaceRuntimeOwnershipAdmissionState + ) -> TerminalSurfaceRuntimeOwnershipReservation { + let reservation = TerminalSurfaceRuntimeOwnershipReservation() + state.reservationIDs.insert(reservation.id) + state.ingressReservationIDs.insert(reservation.id) + state.unclaimedOwnershipIngressReservationIDs.insert(reservation.id) + return reservation + } + + private func takeNextRecoveryGrant( + from state: inout TerminalSurfaceRuntimeOwnershipAdmissionState + ) -> TerminalSurfaceRuntimeOwnershipRecoveryGrant? { + guard !state.closeTeardownDegraded, + !state.closeTeardownAllStalled, + !state.recoveryGrantIsScheduled, + state.reservationIDs.count < maximumOwnerCount, + state.ingressReservationIDs.count < maximumOwnerCount, + let recoveryID = state.recoveryHeadID, + let recoveryAction = removeRecoveryAction( + recoveryID, + from: &state + ) else { + return nil + } + let reservation = reserveOwnershipAndIngress(in: &state) + state.recoveryGrantIsScheduled = true + return TerminalSurfaceRuntimeOwnershipRecoveryGrant( + action: recoveryAction, + reservation: reservation + ) + } + + private func schedule( + _ recoveryGrant: TerminalSurfaceRuntimeOwnershipRecoveryGrant? + ) { + guard let recoveryGrant else { return } + Task { @MainActor in + recoveryGrant.action(recoveryGrant.reservation) + recoveryGrantDidComplete() + } + } + + private func recoveryGrantDidComplete() { + let grant = state.withLock { state in + state.recoveryGrantIsScheduled = false + let grant = takeNextRecoveryGrant(from: &state) + _ = takeRecoveryRescanRequest(from: &state) + return grant + } + schedule(grant) + // The overflow FIFO can proceed only after every older primary grant + // has run. Recheck it after each grant, including the final grant that + // clears `recoveryGrantIsScheduled` without another state transition. + recoveryRescanScheduler.requestRescan() + } + +#if DEBUG + var debugCloseTeardownDegraded: Bool { + state.withLock { $0.closeTeardownDegraded } + } + + var debugCloseTeardownAllStalled: Bool { + state.withLock { $0.closeTeardownAllStalled } + } + + var debugOwnerCount: Int { + state.withLock { $0.reservationIDs.count } + } +#endif +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipAdmissionState.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipAdmissionState.swift new file mode 100644 index 000000000000..a03381d5618c --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipAdmissionState.swift @@ -0,0 +1,19 @@ +import Foundation + +struct TerminalSurfaceRuntimeOwnershipAdmissionState { + var reservationIDs: Set = [] + /// Ingress slots held by live owners or by ordered control submissions. + var ingressReservationIDs: Set = [] + /// Owner slots that have not transferred to an enqueue submission. + var unclaimedOwnershipIngressReservationIDs: Set = [] + var closeTeardownDegraded = false + var closeTeardownAllStalled = false + var recoveryEntriesByID: + [UUID: TerminalSurfaceRuntimeOwnershipRecoveryEntry] = [:] + var recoveryCapacityReservationIDs: Set = [] + var recoveryHeadID: UUID? + var recoveryTailID: UUID? + var pendingRecoveryFailureDeliveryIDs: Set = [] + var recoveryGrantIsScheduled = false + var recoveryRescanRequested = false +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryAdmissionResult.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryAdmissionResult.swift new file mode 100644 index 000000000000..e3d6fff616bd --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryAdmissionResult.swift @@ -0,0 +1,14 @@ +/// Result of synchronous native-surface ownership admission with recovery. +nonisolated enum TerminalSurfaceRuntimeOwnershipRecoveryAdmissionResult: Equatable, Sendable { + /// Ownership was admitted immediately. + case reserved(TerminalSurfaceRuntimeOwnershipReservation) + + /// The bounded recovery queue retained the request for a later grant. + case deferred + + /// The bounded recovery queue rejected the request without retaining it. + case rejected + + /// Every bounded close worker exceeded its watchdog deadline. + case closeTeardownStalled +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation.swift new file mode 100644 index 000000000000..d21c3a1e9bca --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Atomically claims one open recovery-list slot for an overflow rescan. +nonisolated struct TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation: + Sendable +{ + let id = UUID() +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryEntry.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryEntry.swift new file mode 100644 index 000000000000..7450b745c263 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryEntry.swift @@ -0,0 +1,9 @@ +import Foundation + +struct TerminalSurfaceRuntimeOwnershipRecoveryEntry: Sendable { + var action: TerminalSurfaceRuntimeOwnershipRecovery + var failure: TerminalSurfaceRuntimeOwnershipRecoveryFailure + var pendingFailureDeliveryID: UUID? + var previousID: UUID? + var nextID: UUID? +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery.swift new file mode 100644 index 000000000000..6fd804cbe700 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery.swift @@ -0,0 +1,12 @@ +internal import Foundation + +internal struct TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery: Sendable { + internal let recoveryID: UUID + internal let deliveryID: UUID + internal let failure: TerminalSurfaceRuntimeOwnershipRecoveryFailure + + @MainActor + internal func callAsFunction() { + failure() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryFailureDrain.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryFailureDrain.swift new file mode 100644 index 000000000000..c639c0c2043f --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipRecoveryFailureDrain.swift @@ -0,0 +1,109 @@ +internal import os + +/// Delivers stalled native-creation failures in bounded MainActor batches. +/// +/// Admission keeps each queued callback counted against its bounded recovery +/// capacity until this drain delivers it. One transient task owns each batch; +/// idle state has no task cycle. +internal final class TerminalSurfaceRuntimeOwnershipRecoveryFailureDrain: + @unchecked Sendable +{ + private static let maximumBatchCount = 32 + + private struct State { + var failures: [TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery] = [] + var nextFailureIndex = 0 + var task: Task? + } + + private let maximumFailureCount: Int + private let admission: TerminalSurfaceRuntimeOwnershipAdmission + // Safety: this lock owns all mutable failure and task state. Failure + // callbacks leave the lock before they run on the MainActor. + private let state = OSAllocatedUnfairLock(uncheckedState: State()) + + internal init( + maximumFailureCount: Int, + admission: TerminalSurfaceRuntimeOwnershipAdmission + ) { + precondition(maximumFailureCount > 0) + self.maximumFailureCount = maximumFailureCount + self.admission = admission + } + + internal func enqueue( + _ failures: [TerminalSurfaceRuntimeOwnershipRecoveryFailureDelivery] + ) { + guard !failures.isEmpty else { return } + var startGate: TerminalSurfaceRuntimeTeardownStartGate? + state.withLockUnchecked { state in + if state.nextFailureIndex < state.failures.count { + state.failures = state.failures[state.nextFailureIndex...].filter { + admission.ownsStalledCloseFailure($0) + } + state.nextFailureIndex = 0 + } + let pendingCount = state.failures.count - state.nextFailureIndex + precondition( + pendingCount + failures.count <= maximumFailureCount, + "stalled recovery failure drain must remain bounded" + ) + if pendingCount == 0 { + state.failures.removeAll(keepingCapacity: true) + state.nextFailureIndex = 0 + } + state.failures.append(contentsOf: failures) + prepareTaskIfNeeded(state: &state, startGate: &startGate) + } + startGate?.start() + } + + deinit { + state.withLockUnchecked { state in + state.task?.cancel() + state.task = nil + } + } + + private func prepareTaskIfNeeded( + state: inout State, + startGate: inout TerminalSurfaceRuntimeTeardownStartGate? + ) { + guard state.task == nil else { return } + let gate = TerminalSurfaceRuntimeTeardownStartGate() + state.task = Task { @MainActor [self] in + await gate.wait() + runScheduledBatch() + } + startGate = gate + } + + @MainActor + private func runScheduledBatch() { + let batch = state.withLockUnchecked { state in + let endIndex = min( + state.nextFailureIndex + Self.maximumBatchCount, + state.failures.count + ) + let batch = Array(state.failures[state.nextFailureIndex..? + } + + // Safety: this lock owns all mutable links and task state. The weak, + // non-Sendable surface references are dereferenced only by the MainActor + // batch in `runScheduledBatch()`. + private let state = OSAllocatedUnfairLock(uncheckedState: State()) + + internal init(maximumEntryCount: Int) { + precondition(maximumEntryCount > 0) + self.maximumEntryCount = maximumEntryCount + } + + /// Hold the overflow index stable while admission decides where a new + /// request belongs. The scheduler lock is always outside the admission + /// lock, and scheduler callbacks enter admission only after releasing it. + internal func withOverflowQueueFence( + _ body: (Bool) -> Result + ) -> Result { + state.withLockUnchecked { state in + body(state.headID != nil) + } + } + + #if DEBUG + internal var debugSnapshot: + ( + entryCount: Int, + linkedNodeCount: Int, + headID: UUID?, + tailID: UUID? + ) + { + state.withLockUnchecked { state in + var linkedIDs = Set() + var currentID = state.headID + while let nodeID = currentID, linkedIDs.insert(nodeID).inserted { + currentID = state.entriesByID[nodeID]?.nextID + } + return ( + entryCount: state.entriesByID.count, + linkedNodeCount: linkedIDs.count, + headID: state.headID, + tailID: state.tailID + ) + } + } + #endif + + internal func registerOverflow( + surfaceID: UUID, + surface: TerminalSurface + ) -> TerminalSurfaceRuntimeOwnershipRecoveryOverflowRegistration { + var startGate: TerminalSurfaceRuntimeTeardownStartGate? + let registration: TerminalSurfaceRuntimeOwnershipRecoveryOverflowRegistration = + state.withLockUnchecked { state in + if let entry = state.entriesByID[surfaceID] { + entry.surface = surface + entry.failureReported = false + state.rescanRequested = true + prepareRescanTaskIfNeeded(state: &state, startGate: &startGate) + return .updated(sequence: entry.sequence) + } + + guard state.entriesByID.count < maximumEntryCount else { + return .rejected + } + + precondition(state.nextSequence < UInt64.max) + state.nextSequence += 1 + let previousID = state.tailID + let entry = OverflowEntry( + surfaceID: surfaceID, + sequence: state.nextSequence, + surface: surface, + previousID: previousID + ) + state.entriesByID[surfaceID] = entry + if let previousID { + state.entriesByID[previousID]?.nextID = surfaceID + } else { + state.headID = surfaceID + } + state.tailID = surfaceID + state.rescanRequested = true + prepareRescanTaskIfNeeded(state: &state, startGate: &startGate) + return .registered(sequence: entry.sequence) + } + startGate?.start() + return registration + } + + internal func cancelOverflow(surfaceID: UUID) { + let removed = state.withLockUnchecked { state in + removeOverflow(surfaceID: surfaceID, from: &state) != nil + } + if removed { + requestRescan() + } + } + + internal func requestRescan() { + var startGate: TerminalSurfaceRuntimeTeardownStartGate? + state.withLockUnchecked { state in + guard state.headID != nil else { return } + state.rescanRequested = true + prepareRescanTaskIfNeeded(state: &state, startGate: &startGate) + } + startGate?.start() + } + + internal func failPendingOverflowCreations() { + var startGate: TerminalSurfaceRuntimeTeardownStartGate? + state.withLockUnchecked { state in + guard let tailID = state.tailID, + let tail = state.entriesByID[tailID] + else { return } + state.failureThroughSequence = max( + state.failureThroughSequence ?? 0, + tail.sequence + ) + if state.failureCursorID == nil { + state.failureCursorID = state.headID + } + state.rescanRequested = true + prepareRescanTaskIfNeeded(state: &state, startGate: &startGate) + } + startGate?.start() + } + + /// Invalidates failure delivery that was scheduled for a fully stalled + /// close pool. A recovered close slot owns this transition before it opens + /// admission again, so a queued MainActor batch cannot publish a stale + /// capacity error after native ownership capacity has returned. + internal func cancelPendingOverflowFailures() { + state.withLockUnchecked { state in + state.failureThroughSequence = nil + state.failureCursorID = nil + precondition(state.failureCancellationGeneration < UInt64.max) + state.failureCancellationGeneration += 1 + for entry in state.entriesByID.values { + entry.failureReported = false + } + } + } + + deinit { + state.withLockUnchecked { state in + state.rescanTask?.cancel() + state.rescanTask = nil + } + } + + private func prepareRescanTaskIfNeeded( + state: inout State, + startGate: inout TerminalSurfaceRuntimeTeardownStartGate? + ) { + guard state.rescanTask == nil else { return } + let gate = TerminalSurfaceRuntimeTeardownStartGate() + state.rescanTask = Task { @MainActor [weak self] in + await gate.wait() + self?.runScheduledBatch() + } + startGate = gate + } + + @MainActor + private func runScheduledBatch() { + let shouldRun = state.withLockUnchecked { state in + state.rescanRequested = false + return state.headID != nil + } + var processedCount = 0 + if shouldRun { + while processedCount < Self.maximumBatchCount { + if let failure = state.withLockUnchecked({ state in + takeNextFailureEntry(from: &state) + }) { + let failureIsCurrent = state.withLockUnchecked { state in + state.failureThroughSequence != nil + && state.failureCancellationGeneration == failure.generation + } + if failureIsCurrent { + failure.entry.surface? + .failRuntimeSurfaceCreationForTeardownCapacity( + preservingRecoveryOwner: true + ) + } + processedCount += 1 + continue + } + guard + let entry = state.withLockUnchecked({ state in + state.headID.flatMap { state.entriesByID[$0] } + }) + else { + break + } + guard let surface = entry.surface else { + state.withLockUnchecked { state in + _ = removeOverflow( + surfaceID: entry.surfaceID, + from: &state + ) + } + processedCount += 1 + continue + } + guard + let capacityReservation = + surface.runtimeTeardown + .claimRuntimeSurfaceOwnershipRecoveryCapacity() + else { + break + } + surface.retryRuntimeSurfaceCreationAfterAdmissionOverflow( + capacityReservation: capacityReservation + ) + processedCount += 1 + + let madeProgress = state.withLockUnchecked { state in + state.entriesByID[entry.surfaceID] == nil + } + guard madeProgress else { break } + } + } + + let followUp = state.withLockUnchecked { + state -> (OverflowEntry, Bool, Bool)? in + state.rescanTask = nil + let externallyRequested = state.rescanRequested + state.rescanRequested = false + guard let headID = state.headID, + let entry = state.entriesByID[headID] + else { + return nil + } + return ( + entry, + externallyRequested, + state.failureThroughSequence != nil + ) + } + guard let (entry, externallyRequested, failurePending) = followUp else { + return + } + let batchWasExhausted = processedCount == Self.maximumBatchCount + let capacityRemains: Bool + if failurePending { + capacityRemains = true + } else if let surface = entry.surface { + capacityRemains = + surface.runtimeTeardown + .runtimeSurfaceOwnershipRecoveryCapacityIsOpen() + } else { + capacityRemains = true + } + if capacityRemains + && (batchWasExhausted || externallyRequested || failurePending) + { + requestRescan() + } + } + + @discardableResult + private func removeOverflow( + surfaceID: UUID, + from state: inout State + ) -> OverflowEntry? { + guard let entry = state.entriesByID.removeValue(forKey: surfaceID) else { + return nil + } + if let previousID = entry.previousID { + state.entriesByID[previousID]?.nextID = entry.nextID + } else { + state.headID = entry.nextID + } + if let nextID = entry.nextID { + state.entriesByID[nextID]?.previousID = entry.previousID + } else { + state.tailID = entry.previousID + } + if state.failureCursorID == surfaceID { + state.failureCursorID = entry.nextID + } + if state.entriesByID.isEmpty { + state.headID = nil + state.tailID = nil + state.failureThroughSequence = nil + state.failureCursorID = nil + state.rescanRequested = false + } + return entry + } + + private func takeNextFailureEntry( + from state: inout State + ) -> (entry: OverflowEntry, generation: UInt64)? { + guard let cutoff = state.failureThroughSequence else { return nil } + while let currentID = state.failureCursorID, + let entry = state.entriesByID[currentID] + { + guard entry.sequence <= cutoff else { break } + state.failureCursorID = entry.nextID + guard !entry.failureReported else { continue } + entry.failureReported = true + return (entry, state.failureCancellationGeneration) + } + state.failureThroughSequence = nil + state.failureCursorID = nil + return nil + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipReservation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipReservation.swift new file mode 100644 index 000000000000..92d84a202f95 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeOwnershipReservation.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Ownership of one live or retained native Ghostty surface. +/// +/// The reservation begins before `ghostty_surface_new` and ends only after +/// `ghostty_surface_free` returns. This bounds the combined live-surface and +/// teardown backlog even when every native-free worker is stalled. +struct TerminalSurfaceRuntimeOwnershipReservation: Equatable, Sendable { + let id: UUID + + init(id: UUID = UUID()) { + self.id = id + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimePreparedTeardownExecution.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimePreparedTeardownExecution.swift new file mode 100644 index 000000000000..647627182bb4 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimePreparedTeardownExecution.swift @@ -0,0 +1,17 @@ +/// A native-free Task that cannot run before its coordinator stores the handle. +nonisolated struct TerminalSurfaceRuntimePreparedTeardownExecution: Sendable { + let task: Task + private let startGate: TerminalSurfaceRuntimeTeardownStartGate + + init( + task: Task, + startGate: TerminalSurfaceRuntimeTeardownStartGate + ) { + self.task = task + self.startGate = startGate + } + + func start() { + startGate.start() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator+Debug.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator+Debug.swift index 7aef9d9a5349..a1ef85c01f99 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator+Debug.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator+Debug.swift @@ -10,7 +10,23 @@ extension TerminalSurfaceRuntimeTeardownCoordinator { /// host; suites that create surfaces assert this drained back to their /// baseline after teardown. public var debugPendingTeardownCount: Int { - pendingReasonsById.count + pendingRequestsById.count + } + + /// Test support: whether both close slots are active and new ownership is + /// fenced until one worker returns. + public var debugCloseTeardownDegraded: Bool { + runtimeOwnershipAdmission.debugCloseTeardownDegraded + } + + /// Test support: both close workers exceeded their watchdog deadlines. + public nonisolated var debugCloseTeardownAllStalled: Bool { + runtimeOwnershipAdmission.debugCloseTeardownAllStalled + } + + /// Test support: live plus retained native surfaces charged to admission. + public nonisolated var debugRuntimeSurfaceOwnerCount: Int { + runtimeOwnershipAdmission.debugOwnerCount } } #endif diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 022b1c7621b6..6514d0b814a4 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -1,63 +1,223 @@ public import Foundation public import GhosttyKit public import CmuxTerminalCore -internal import Dispatch #if DEBUG internal import CMUXDebugLog #endif /// Coordinates native `ghostty_surface_free` calls off the close/deinit paths. /// -/// Close/deinit frees run on a bounded set of utility slots so one stuck native -/// join cannot strand later closes. Each admitted hibernation owns a separate, -/// independently startable utility slot. Deadline observers report, but never -/// block on, stuck frees. The app constructs exactly one instance and injects it -/// through +/// Close/deinit frees use two independently startable utility slots. Each slot +/// remains serial, so teardown cannot recursively reuse one worker, while one +/// stuck native join leaves the other slot available to drain later closes. +/// Every native surface holds a bounded ownership reservation from creation +/// through completed free. While both close slots are occupied, new ownership +/// waits for a worker-completion signal, so retained frees cannot grow without +/// bound. If both workers exceed their five-second watchdog, queued creations +/// report a visible failure and retain their bounded FIFO owners. A late native +/// free keeps its pointer ownership, restores admission only after it returns, +/// and retries those creations in their original order. +/// Each admitted hibernation also owns one independently startable utility slot. +/// Slot occupancy fences new ownership until one active free completes. The app constructs +/// exactly one instance and injects it through /// ``TerminalSurfaceRuntimeDependencies``. public actor TerminalSurfaceRuntimeTeardownCoordinator { - /// Maximum number of close/deinit native frees that can run concurrently. + /// Maximum native close frees that can run concurrently. public static let maximumConcurrentCloseTeardownCount = 2 + /// Maximum live plus retained native surfaces owned by one coordinator. + public static let maximumRuntimeSurfaceOwnerCount = 4_096 + /// Largest batch that can own independently startable native-free slots. public static let maximumIsolatedHibernationTeardownCount = 2 - private let timeout: Duration = .seconds(5) + private let closeTeardownTimeout: Duration + private nonisolated let closeTeardownClock: any Clock + private let closeTeardownStalledObserver: @Sendable (Int) -> Void + private nonisolated let submissionDrain: + TerminalSurfaceRuntimeTeardownSubmissionDrain + private nonisolated let recoveryRescanScheduler: + TerminalSurfaceRuntimeOwnershipRecoveryRescanScheduler + private nonisolated let recoveryFailureDrain: + TerminalSurfaceRuntimeOwnershipRecoveryFailureDrain #if DEBUG // Readable at internal scope in DEBUG so the debug-only extension in // TerminalSurfaceRuntimeTeardownCoordinator+Debug.swift can report the // pending count; private in release builds. - var pendingReasonsById: [UUID: String] = [:] + var pendingRequestsById: [UUID: (surfaceID: UUID, reason: String)] = [:] #else - private var pendingReasonsById: [UUID: String] = [:] + private var pendingRequestsById: [UUID: (surfaceID: UUID, reason: String)] = [:] #endif private var queuedCloseRequests: [TerminalSurfaceRuntimeTeardownRequest] = [] private var availableCloseExecutionSlots: Set - private let closeTeardownQueues: [DispatchQueue] - private let isolatedHibernationQueues: [DispatchQueue] + private var activeCloseTeardownsBySlot: + [Int: TerminalSurfaceRuntimeActiveTeardown] = [:] + private var activeHibernationTeardownsBySlot: + [Int: TerminalSurfaceRuntimeActiveTeardown] = [:] + private var stalledCloseExecutionSlots: Set = [] + private var cancelledTicketIDs: Set = [] +#if DEBUG + // Readable at internal scope in DEBUG so the debug-only extension in + // TerminalSurfaceRuntimeTeardownCoordinator+Debug.swift can report + // admission health; private in release builds. + nonisolated let runtimeOwnershipAdmission: + TerminalSurfaceRuntimeOwnershipAdmission +#else + private nonisolated let runtimeOwnershipAdmission: + TerminalSurfaceRuntimeOwnershipAdmission +#endif private nonisolated let isolatedHibernationAdmission = TerminalSurfaceRuntimeTeardownAdmission() /// Creates the process's teardown coordinator. public init() { + closeTeardownTimeout = .seconds(5) + closeTeardownClock = ContinuousClock() + closeTeardownStalledObserver = { _ in } + let recoveryRescanScheduler = + TerminalSurfaceRuntimeOwnershipRecoveryRescanScheduler( + maximumEntryCount: Self.maximumRuntimeSurfaceOwnerCount + ) + submissionDrain = TerminalSurfaceRuntimeTeardownSubmissionDrain( + maximumBufferedOperationCount: + Self.maximumRuntimeSurfaceOwnerCount + ) + let runtimeOwnershipAdmission = TerminalSurfaceRuntimeOwnershipAdmission( + maximumOwnerCount: Self.maximumRuntimeSurfaceOwnerCount, + recoveryRescanScheduler: recoveryRescanScheduler + ) + self.runtimeOwnershipAdmission = runtimeOwnershipAdmission + recoveryFailureDrain = + TerminalSurfaceRuntimeOwnershipRecoveryFailureDrain( + maximumFailureCount: Self.maximumRuntimeSurfaceOwnerCount, + admission: runtimeOwnershipAdmission + ) + self.recoveryRescanScheduler = recoveryRescanScheduler availableCloseExecutionSlots = Set( 0.. = ContinuousClock(), + closeTeardownStalledObserver: @escaping @Sendable (Int) -> Void = { _ in } + ) { + precondition(closeTeardownTimeout > .zero) + self.closeTeardownTimeout = closeTeardownTimeout + self.closeTeardownClock = closeTeardownClock + self.closeTeardownStalledObserver = closeTeardownStalledObserver + let recoveryRescanScheduler = + TerminalSurfaceRuntimeOwnershipRecoveryRescanScheduler( + maximumEntryCount: maximumRuntimeSurfaceOwnerCount ) - } - isolatedHibernationQueues = ( - 0.. TerminalSurfaceRuntimeOwnershipReservation? { + runtimeOwnershipAdmission.reserve() + } + + nonisolated func reserveRuntimeSurfaceOwnership( + recoveryID: UUID, + onRecovery: @escaping TerminalSurfaceRuntimeOwnershipRecovery, + onFailure: @escaping TerminalSurfaceRuntimeOwnershipRecoveryFailure = {}, + capacityReservation: + TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation? = nil + ) -> TerminalSurfaceRuntimeOwnershipRecoveryAdmissionResult { + runtimeOwnershipAdmission.reserve( + recoveryID: recoveryID, + onRecovery: onRecovery, + onFailure: onFailure, + capacityReservation: capacityReservation + ) + } + + nonisolated func registerRuntimeSurfaceOwnershipRecoveryOverflow( + surfaceID: UUID, + surface: TerminalSurface + ) -> TerminalSurfaceRuntimeOwnershipRecoveryOverflowRegistration { + recoveryRescanScheduler.registerOverflow( + surfaceID: surfaceID, + surface: surface + ) + } + + nonisolated func cancelRuntimeSurfaceOwnershipRecoveryOverflow( + surfaceID: UUID + ) { + recoveryRescanScheduler.cancelOverflow(surfaceID: surfaceID) + } + + nonisolated func requestRuntimeSurfaceOwnershipRecoveryRescan() { + recoveryRescanScheduler.requestRescan() + } + +#if DEBUG + nonisolated var debugRuntimeSurfaceOwnershipRecoveryOverflowSnapshot: ( + entryCount: Int, + linkedNodeCount: Int, + headID: UUID?, + tailID: UUID? + ) { + recoveryRescanScheduler.debugSnapshot + } +#endif + + nonisolated func claimRuntimeSurfaceOwnershipRecoveryCapacity() + -> TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation? { + runtimeOwnershipAdmission.claimRecoveryCapacity() + } + + nonisolated func runtimeSurfaceOwnershipRecoveryCapacityIsOpen() -> Bool { + runtimeOwnershipAdmission.recoveryCapacityIsOpen() + } + + nonisolated func cancelRuntimeSurfaceOwnershipRecoveryCapacity( + _ reservation: + TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation + ) { + runtimeOwnershipAdmission.releaseRecoveryCapacity(reservation) + } + + nonisolated func cancelRuntimeSurfaceOwnershipRecovery(_ recoveryID: UUID) { + runtimeOwnershipAdmission.cancelRecovery(recoveryID) + } + + nonisolated func cancelRuntimeSurfaceOwnership( + _ reservation: TerminalSurfaceRuntimeOwnershipReservation + ) { + runtimeOwnershipAdmission.release(reservation) } @MainActor @@ -83,43 +243,6 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { request.read() } - /// Queues a native-surface free from any isolation (the surface model's - /// `deinit` is nonisolated and cannot await). - /// - /// - Parameters: - /// - id: The owning surface id. - /// - workspaceId: The owning workspace id. - /// - reason: The teardown reason, for diagnostics. - /// - surface: The native surface pointer, already removed from all - /// main-thread owner state. - /// - callbackContext: The retained callback context released on the - /// main actor after the free completes. - /// - freeSurface: The free operation; defaults to - /// `ghostty_surface_free`. - /// - Returns: A ticket that completes after the native free and userdata releases. - @discardableResult - public nonisolated func enqueueRuntimeTeardown( - id: UUID, - workspaceId: UUID, - reason: String, - surface: ghostty_surface_t, - callbackContext: Unmanaged?, - freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void = { surface in - ghostty_surface_free(surface) - } - ) -> TerminalSurfaceRuntimeTeardownTicket { - enqueueRuntimeTeardown( - id: id, - workspaceId: workspaceId, - reason: reason, - surface: surface, - callbackContext: callbackContext, - manualIOContext: nil, - byteTeeLease: nil, - freeSurface: freeSurface - ) - } - /// Queues a native-surface free that also transports the surface's other /// retained callback userdata. /// @@ -145,7 +268,10 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { /// actor after the free completes. /// - freeSurface: The free operation; defaults to /// `ghostty_surface_free`. - /// - Returns: A ticket that completes after the native free and userdata releases. + /// - Returns: A ticket that completes after the native free and userdata + /// releases, or `nil` when the supplied ownership reservation is invalid, + /// was already transferred, or the submission stream has terminated. On + /// `nil`, the caller retains the native pointer and its callback userdata. @discardableResult nonisolated func enqueueRuntimeTeardown( id: UUID, @@ -155,16 +281,25 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { callbackContext: Unmanaged?, manualIOContext: Unmanaged?, byteTeeLease: (any TerminalByteTeeLease)?, + runtimeOwnershipReservation: + TerminalSurfaceRuntimeOwnershipReservation, executionLane: TerminalSurfaceRuntimeTeardownExecutionLane = .boundedClose, isolatedHibernationReservation: TerminalSurfaceRuntimeTeardownReservation? = nil, freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void = { surface in ghostty_surface_free(surface) } - ) -> TerminalSurfaceRuntimeTeardownTicket { + ) -> TerminalSurfaceRuntimeTeardownTicket? { + guard let ingressReservation = + runtimeOwnershipAdmission.claimIngressReservation( + for: runtimeOwnershipReservation + ) else { + return nil + } let completion = TerminalSurfaceRuntimeTeardownCompletion() let ticket = TerminalSurfaceRuntimeTeardownTicket(completion: completion) let request = TerminalSurfaceRuntimeTeardownRequest( + ticketID: ticket.id, id: id, workspaceId: workspaceId, reason: reason, @@ -172,52 +307,147 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: byteTeeLease, + runtimeOwnershipReservation: runtimeOwnershipReservation, freeSurface: freeSurface, completion: completion ) - Task { - await self.enqueue( + let submission = TerminalSurfaceRuntimeTeardownSubmission.enqueue( + request: request, + executionLane: executionLane, + isolatedHibernationReservation: isolatedHibernationReservation, + ingressReservation: ingressReservation + ) + let yieldResult = submissionDrain.yield { [self] in + await receive(submission) + } + switch yieldResult { + case .enqueued: + return ticket + case .dropped, .terminated: + runtimeOwnershipAdmission.releaseFailedSubmission( + ownership: runtimeOwnershipReservation, + ingress: ingressReservation + ) + return nil + @unknown default: + runtimeOwnershipAdmission.releaseFailedSubmission( + ownership: runtimeOwnershipReservation, + ingress: ingressReservation + ) + return nil + } + } + + /// Requests cancellation without abandoning the owned native free. + /// + /// Cancellation is ordered with enqueue through the same ingress. A + /// cancelled Task still performs the non-cancellable native free and then + /// releases its reservation through the normal completion path. + @discardableResult + nonisolated func cancelRuntimeTeardown(ticketID: UUID) async -> Bool { + guard let ingressReservation = + runtimeOwnershipAdmission.reserveControlIngress() else { + return false + } + let result = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + let submission = TerminalSurfaceRuntimeTeardownSubmission.cancel( + ticketID: ticketID, + result: result.continuation, + ingressReservation: ingressReservation + ) + let yieldResult = submissionDrain.yield { [self] in + await receive(submission) + } + switch yieldResult { + case .enqueued: + var iterator = result.stream.makeAsyncIterator() + return await iterator.next() ?? false + case .dropped, .terminated: + runtimeOwnershipAdmission.releaseIngress(ingressReservation) + result.continuation.finish() + return false + @unknown default: + runtimeOwnershipAdmission.releaseIngress(ingressReservation) + result.continuation.finish() + return false + } + } + + nonisolated func cancelAllRuntimeTeardowns() { + guard let ingressReservation = + runtimeOwnershipAdmission.reserveControlIngress() else { + return + } + let submission = TerminalSurfaceRuntimeTeardownSubmission.cancelAll( + ingressReservation: ingressReservation + ) + let yieldResult = submissionDrain.yield { [self] in + await receive(submission) + } + switch yieldResult { + case .enqueued: + break + case .dropped, .terminated: + runtimeOwnershipAdmission.releaseIngress(ingressReservation) + @unknown default: + runtimeOwnershipAdmission.releaseIngress(ingressReservation) + } + } + + private func receive( + _ submission: TerminalSurfaceRuntimeTeardownSubmission + ) async { + switch submission { + case .enqueue( + let request, + let executionLane, + let isolatedHibernationReservation, + let ingressReservation + ): + runtimeOwnershipAdmission.releaseIngress(ingressReservation) + await enqueue( request, executionLane: executionLane, - isolatedHibernationReservation: isolatedHibernationReservation + isolatedHibernationReservation: + isolatedHibernationReservation ) + case .cancel(let ticketID, let result, let ingressReservation): + runtimeOwnershipAdmission.releaseIngress(ingressReservation) + result.yield(cancelStoredTeardown(ticketID: ticketID)) + result.finish() + case .cancelAll(let ingressReservation): + runtimeOwnershipAdmission.releaseIngress(ingressReservation) + cancelAllStoredTeardowns() } - return ticket } - func enqueue( + private func enqueue( _ request: TerminalSurfaceRuntimeTeardownRequest, - executionLane: TerminalSurfaceRuntimeTeardownExecutionLane = .boundedClose, + executionLane: TerminalSurfaceRuntimeTeardownExecutionLane, isolatedHibernationReservation: - TerminalSurfaceRuntimeTeardownReservation? = nil + TerminalSurfaceRuntimeTeardownReservation? ) async { - pendingReasonsById[request.id] = request.reason + pendingRequestsById[request.ticketID] = ( + surfaceID: request.id, + reason: request.reason + ) switch executionLane { case .isolatedHibernation: if let isolatedHibernationReservation, let executionSlot = await isolatedHibernationAdmission.executionSlot( for: isolatedHibernationReservation ), - isolatedHibernationQueues.indices.contains(executionSlot) { - // Each reservation exclusively owns one queue until its native free - // returns. Ghostty locks its shared surface registry, while renderer - // and IO joins are surface-owned, so separate surfaces may tear down - // concurrently. This bounds blocked native workers at two without - // letting one stuck pane strand another admitted pane. + !activeHibernationTeardownsBySlot.keys.contains(executionSlot) { await Self.invalidateRuntimeClipboardRequestsBeforeFree(request) - Task { - await self.observeTimeout(id: request.id) - } - isolatedHibernationQueues[executionSlot].async { - self.freeNativeSurface(request) - Task { - await self.isolatedHibernationAdmission.release( - isolatedHibernationReservation - ) - await self.finishFree(request) - await self.complete(id: request.id) - } - } + startTeardown( + request, + executionLane: .isolatedHibernation, + executionSlot: executionSlot, + isolatedHibernationReservation: + isolatedHibernationReservation + ) return } if let isolatedHibernationReservation { @@ -237,30 +467,194 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { while !queuedCloseRequests.isEmpty, let executionSlot = availableCloseExecutionSlots.min() { availableCloseExecutionSlots.remove(executionSlot) - let request = queuedCloseRequests.removeFirst() - Task { - await self.observeTimeout(id: request.id) - } - closeTeardownQueues[executionSlot].async { + startTeardown( + queuedCloseRequests.removeFirst(), + executionLane: .boundedClose, + executionSlot: executionSlot, + isolatedHibernationReservation: nil + ) + } + } + + private func startTeardown( + _ request: TerminalSurfaceRuntimeTeardownRequest, + executionLane: TerminalSurfaceRuntimeTeardownExecutionLane, + executionSlot: Int, + isolatedHibernationReservation: + TerminalSurfaceRuntimeTeardownReservation? + ) { + // This method is reachable only after one of two close slots or two + // isolated-hibernation slots is admitted, which caps worker threads at 4. + let prepared = executionLane.prepare( + operation: { self.freeNativeSurface(request) - Task { - await self.finishCloseTeardown( - request, - executionSlot: executionSlot - ) - } + }, + completion: { + await self.finishTeardown( + request, + executionLane: executionLane, + executionSlot: executionSlot, + isolatedHibernationReservation: + isolatedHibernationReservation + ) } + ) + let active = TerminalSurfaceRuntimeActiveTeardown( + ticketID: request.ticketID, + task: prepared.task, + watchdogTask: makeCloseTeardownWatchdog( + request: request, + executionLane: executionLane, + executionSlot: executionSlot + ) + ) + switch executionLane { + case .boundedClose: + activeCloseTeardownsBySlot[executionSlot] = active + updateCloseTeardownAdmission() + case .isolatedHibernation: + activeHibernationTeardownsBySlot[executionSlot] = active + } + if cancelledTicketIDs.remove(request.ticketID) != nil { + prepared.task.cancel() + } + prepared.start() + } + + private func cancelStoredTeardown(ticketID: UUID) -> Bool { + if let active = activeCloseTeardownsBySlot.values.first( + where: { $0.ticketID == ticketID } + ) { + active.task.cancel() + return true + } + if let active = activeHibernationTeardownsBySlot.values.first( + where: { $0.ticketID == ticketID } + ) { + active.task.cancel() + return true + } + if queuedCloseRequests.contains( + where: { $0.ticketID == ticketID } + ) { + cancelledTicketIDs.insert(ticketID) + return true + } + return false + } + + private func cancelAllStoredTeardowns() { + cancelledTicketIDs.formUnion( + queuedCloseRequests.map(\.ticketID) + ) + for active in activeCloseTeardownsBySlot.values { + active.task.cancel() + } + for active in activeHibernationTeardownsBySlot.values { + active.task.cancel() } } - private func finishCloseTeardown( + private func finishTeardown( _ request: TerminalSurfaceRuntimeTeardownRequest, - executionSlot: Int + executionLane: TerminalSurfaceRuntimeTeardownExecutionLane, + executionSlot: Int, + isolatedHibernationReservation: + TerminalSurfaceRuntimeTeardownReservation? ) async { + let active: TerminalSurfaceRuntimeActiveTeardown? + switch executionLane { + case .boundedClose: + active = activeCloseTeardownsBySlot[executionSlot] + case .isolatedHibernation: + active = activeHibernationTeardownsBySlot[executionSlot] + } + guard active?.ticketID == request.ticketID else { return } + active?.watchdogTask?.cancel() + + var recoveredStalledCloseSlot = false + switch executionLane { + case .boundedClose: + activeCloseTeardownsBySlot.removeValue(forKey: executionSlot) + recoveredStalledCloseSlot = stalledCloseExecutionSlots.remove(executionSlot) != nil + case .isolatedHibernation: + activeHibernationTeardownsBySlot.removeValue(forKey: executionSlot) + } + + if recoveredStalledCloseSlot { + updateCloseTeardownAdmission() + } + await finishFree(request) - complete(id: request.id) - availableCloseExecutionSlots.insert(executionSlot) - startAvailableCloseTeardowns() + complete(requestID: request.ticketID) + + switch executionLane { + case .boundedClose: + availableCloseExecutionSlots.insert(executionSlot) + updateCloseTeardownAdmission() + startAvailableCloseTeardowns() + case .isolatedHibernation: + if let isolatedHibernationReservation { + await isolatedHibernationAdmission.release( + isolatedHibernationReservation + ) + } + } + } + + private func updateCloseTeardownAdmission() { + if stalledCloseExecutionSlots.count + < Self.maximumConcurrentCloseTeardownCount { + if runtimeOwnershipAdmission + .clearAllStalledCloseTeardownsIfNeeded() { + recoveryRescanScheduler.cancelPendingOverflowFailures() + } + } + runtimeOwnershipAdmission.setCloseTeardownDegraded( + activeCloseTeardownsBySlot.count + == Self.maximumConcurrentCloseTeardownCount + ) + } + + private func makeCloseTeardownWatchdog( + request: TerminalSurfaceRuntimeTeardownRequest, + executionLane: TerminalSurfaceRuntimeTeardownExecutionLane, + executionSlot: Int + ) -> Task? { + guard executionLane == .boundedClose else { return nil } + let timeout = closeTeardownTimeout + let clock = closeTeardownClock + return Task { + do { + try await clock.sleep(for: timeout, tolerance: nil) + } catch { + return + } + guard !Task.isCancelled else { return } + closeTeardownWatchdogExpired( + ticketID: request.ticketID, + executionSlot: executionSlot + ) + } + } + + private func closeTeardownWatchdogExpired( + ticketID: UUID, + executionSlot: Int + ) { + guard activeCloseTeardownsBySlot[executionSlot]?.ticketID == ticketID, + stalledCloseExecutionSlots.insert(executionSlot).inserted else { + return + } + closeTeardownStalledObserver(executionSlot) + guard stalledCloseExecutionSlots.count + == Self.maximumConcurrentCloseTeardownCount else { + return + } + let failures = runtimeOwnershipAdmission + .failRecoveriesForAllStalledCloseTeardowns() + recoveryRescanScheduler.failPendingOverflowCreations() + recoveryFailureDrain.enqueue(failures) } private nonisolated static func invalidateRuntimeClipboardRequestsBeforeFree( @@ -305,6 +699,9 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { request.byteTeeLease?.release() } } + runtimeOwnershipAdmission.release( + request.runtimeOwnershipReservation + ) await request.completion.finish() #if DEBUG logDebugEvent( @@ -314,23 +711,8 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { #endif } - private func complete(id: UUID) { - pendingReasonsById.removeValue(forKey: id) + private func complete(requestID: UUID) { + pendingRequestsById.removeValue(forKey: requestID) } - private func observeTimeout(id: UUID) async { - do { - // Genuine teardown deadline: report a stuck native free without blocking close. - try await Task.sleep(for: timeout) - } catch { - return - } - guard let reason = pendingReasonsById[id] else { return } -#if DEBUG - logDebugEvent( - "surface.lifecycle.nativeFree.timeout surface=\(id.uuidString.prefix(5)) " + - "reason=\(reason)" - ) -#endif - } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownExecutionLane.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownExecutionLane.swift index 35e4cb97fbd7..ff82b7cbb82c 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownExecutionLane.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownExecutionLane.swift @@ -1,8 +1,42 @@ +internal import Foundation + /// Selects the ownership boundary for a native surface free. -enum TerminalSurfaceRuntimeTeardownExecutionLane: Sendable { - /// Uses the bounded close/deinit pool without blocking later frees. +nonisolated enum TerminalSurfaceRuntimeTeardownExecutionLane: Sendable { + /// Uses the bounded, failure-isolated close worker pool. case boundedClose /// Gives an explicitly owned hibernation join an independent bounded slot. case isolatedHibernation + + /// Creates the one Task that owns a slot's native free. + /// + /// The checked-continuation gate makes storage happen before execution. It + /// intentionally does not treat Task cancellation as permission to skip a + /// native free, because the request owns the surface until free completes. + /// The coordinator calls this only after slot admission, so buffered + /// submissions never create threads. Each admitted Task creates one Thread, + /// runs one blocking free, resumes once, and lets that Thread return. + nonisolated func prepare( + operation: @escaping @Sendable () -> Void, + completion: @escaping @Sendable () async -> Void + ) -> TerminalSurfaceRuntimePreparedTeardownExecution { + let startGate = TerminalSurfaceRuntimeTeardownStartGate() + let task = Task(priority: .utility) { + await startGate.wait() + await withCheckedContinuation { continuation in + let thread = Thread { + operation() + continuation.resume() + } + thread.name = "com.cmux.runtime-teardown" + thread.qualityOfService = .utility + thread.start() + } + await completion() + } + return TerminalSurfaceRuntimePreparedTeardownExecution( + task: task, + startGate: startGate + ) + } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownIngressReservation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownIngressReservation.swift new file mode 100644 index 000000000000..f4e89769c676 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownIngressReservation.swift @@ -0,0 +1,9 @@ +import Foundation + +internal struct TerminalSurfaceRuntimeTeardownIngressReservation: Sendable { + let id: UUID + + init(id: UUID = UUID()) { + self.id = id + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift index 18d82e8f78b1..576421adfbce 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift @@ -17,6 +17,7 @@ public import CmuxTerminalCore /// tee callback and the io thread that fires the MANUAL-mode `io_write_cb`), /// so a release ordered after the free can never race an in-flight callback. struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { + let ticketID: UUID let id: UUID let workspaceId: UUID let reason: String @@ -24,6 +25,7 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { let callbackContext: Unmanaged? let manualIOContext: Unmanaged? let byteTeeLease: (any TerminalByteTeeLease)? + let runtimeOwnershipReservation: TerminalSurfaceRuntimeOwnershipReservation let freeSurface: @Sendable (ghostty_surface_t) -> Void let completion: TerminalSurfaceRuntimeTeardownCompletion #if DEBUG @@ -32,6 +34,7 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { #endif init( + ticketID: UUID, id: UUID, workspaceId: UUID, reason: String, @@ -39,9 +42,11 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { callbackContext: Unmanaged?, manualIOContext: Unmanaged?, byteTeeLease: (any TerminalByteTeeLease)?, + runtimeOwnershipReservation: TerminalSurfaceRuntimeOwnershipReservation, freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void, completion: TerminalSurfaceRuntimeTeardownCompletion ) { + self.ticketID = ticketID self.id = id self.workspaceId = workspaceId self.reason = reason @@ -49,6 +54,7 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { self.callbackContext = callbackContext self.manualIOContext = manualIOContext self.byteTeeLease = byteTeeLease + self.runtimeOwnershipReservation = runtimeOwnershipReservation self.freeSurface = freeSurface self.completion = completion #if DEBUG diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownStartGate.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownStartGate.swift new file mode 100644 index 000000000000..2d6e857554d3 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownStartGate.swift @@ -0,0 +1,53 @@ +internal import os + +/// Safety: the lock protects every state transition and the stored continuation. +/// The single waiter and idempotent start move that continuation out at most +/// once, and resume it only after releasing the lock. The coordinator must +/// store the prepared Task before `start()` releases it; actor suspension at +/// this boundary could break that order. +final class TerminalSurfaceRuntimeTeardownStartGate: @unchecked Sendable { + private enum State { + case pending + case waiting(CheckedContinuation) + case started + } + + private let state = OSAllocatedUnfairLock(initialState: State.pending) + + func wait() async { + await withCheckedContinuation { continuation in + let startImmediately = state.withLock { state in + switch state { + case .pending: + state = .waiting(continuation) + return false + case .started: + return true + case .waiting: + preconditionFailure( + "teardown start gate waited more than once" + ) + } + } + if startImmediately { + continuation.resume() + } + } + } + + func start() { + let continuation: CheckedContinuation? = state.withLock { state in + switch state { + case .pending: + state = .started + return nil + case .waiting(let continuation): + state = .started + return continuation + case .started: + return nil + } + } + continuation?.resume() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownSubmission.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownSubmission.swift new file mode 100644 index 000000000000..750d5558c7f7 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownSubmission.swift @@ -0,0 +1,18 @@ +import Foundation + +internal enum TerminalSurfaceRuntimeTeardownSubmission: Sendable { + case enqueue( + request: TerminalSurfaceRuntimeTeardownRequest, + executionLane: TerminalSurfaceRuntimeTeardownExecutionLane, + isolatedHibernationReservation: TerminalSurfaceRuntimeTeardownReservation?, + ingressReservation: TerminalSurfaceRuntimeTeardownIngressReservation + ) + case cancel( + ticketID: UUID, + result: AsyncStream.Continuation, + ingressReservation: TerminalSurfaceRuntimeTeardownIngressReservation + ) + case cancelAll( + ingressReservation: TerminalSurfaceRuntimeTeardownIngressReservation + ) +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownSubmissionDrain.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownSubmissionDrain.swift new file mode 100644 index 000000000000..872d88fddca9 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownSubmissionDrain.swift @@ -0,0 +1,34 @@ +/// Drains accepted teardown operations in ingress order. +/// +/// The consumer Task captures only the stream. Each buffered operation retains +/// its coordinator until that operation reaches the actor receive boundary. +internal final class TerminalSurfaceRuntimeTeardownSubmissionDrain: Sendable { + internal typealias Operation = @Sendable () async -> Void + + private let continuation: AsyncStream.Continuation + private let consumerTask: Task + + internal init(maximumBufferedOperationCount: Int) { + let submissions = AsyncStream.makeStream( + bufferingPolicy: .bufferingOldest(maximumBufferedOperationCount) + ) + let stream = submissions.stream + continuation = submissions.continuation + consumerTask = Task { + for await operation in stream { + await operation() + } + } + } + + deinit { + continuation.finish() + consumerTask.cancel() + } + + internal func yield( + _ operation: @escaping Operation + ) -> AsyncStream.Continuation.YieldResult { + continuation.yield(operation) + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift index 245ff49ec937..fbdf1c6547d6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift @@ -182,16 +182,33 @@ extension TerminalSurface { let callbackContext = surfaceCallbackContext invalidateRuntimeClipboardRequests(in: callbackContext, completingNativeRequests: surface != nil) surfaceCallbackContext = nil + let runtimeOwnershipReservation = + runtimeSurfaceOwnershipReservation + self.runtimeSurfaceOwnershipReservation = nil guard let surfaceToFree = surface else { callbackContext?.release() + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } return } registry.unregisterRuntimeSurface(surfaceToFree, ownerId: id) surface = nil - ghostty_surface_free(surfaceToFree) + if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting { + freeSurface(surfaceToFree) + } else { + ghostty_surface_free(surfaceToFree) + } callbackContext?.release() + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } } /// Test-only helper to simulate a stale Swift wrapper whose native surface @@ -203,9 +220,17 @@ extension TerminalSurface { let callbackContext = surfaceCallbackContext invalidateRuntimeClipboardRequests(in: callbackContext, completingNativeRequests: surface != nil) surfaceCallbackContext = nil + let runtimeOwnershipReservation = + runtimeSurfaceOwnershipReservation + self.runtimeSurfaceOwnershipReservation = nil guard let surfaceToFree = surface else { callbackContext?.release() + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } return } @@ -213,11 +238,26 @@ extension TerminalSurface { ghostty_surface_free(surfaceToFree) runtimeSurfaceFreedOutOfBandForTesting = true callbackContext?.release() + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } } /// Test-only helper to install a runtime surface pointer directly. @MainActor public func installRuntimeSurfaceForTesting(_ runtimeSurface: ghostty_surface_t) { + if runtimeSurfaceOwnershipReservation == nil { + guard let runtimeOwnershipReservation = + runtimeTeardown.reserveRuntimeSurfaceOwnership() else { + preconditionFailure( + "Test runtime surface requires bounded runtime ownership" + ) + } + self.runtimeSurfaceOwnershipReservation = + runtimeOwnershipReservation + } let callbackContext: Unmanaged< GhosttySurfaceCallbackContext > @@ -248,6 +288,7 @@ extension TerminalSurface { on: runtimeSurface, callbackContext: callbackContext ) + paneHost.clearRuntimeSurfaceCreationFailure() } #endif } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 255dd658aba7..b62de755f7f5 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -165,6 +165,9 @@ extension TerminalSurface { surfaceCallbackContext = nil let teeLease = mobileByteTeeLease mobileByteTeeLease = nil + let runtimeOwnershipReservation = + runtimeSurfaceOwnershipReservation + self.runtimeSurfaceOwnershipReservation = nil registry.unregisterRuntimeSurface(surface, ownerId: id) self.surface = nil activePortalHostLease = nil @@ -181,6 +184,11 @@ extension TerminalSurface { #endif callbackContext?.release() teeLease?.release() + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } return nil } return surface @@ -242,6 +250,7 @@ extension TerminalSurface { guard portalLifecycleState != .closing else { return } recordTeardownRequest(reason: reason) portalLifecycleState = .closing + cancelRuntimeSurfaceCreationAfterAdmissionRecovery() // Parked wake-ups are for re-anchoring live content; a closing surface // has none, and the park guard refuses new entries from here on. clearPortalHostVacancyRetries() @@ -258,6 +267,7 @@ extension TerminalSurface { func markPortalLifecycleClosed(reason: String) { guard portalLifecycleState != .closed else { return } portalLifecycleState = .closed + cancelRuntimeSurfaceCreationAfterAdmissionRecovery() portalLifecycleGeneration &+= 1 clearPortalHostVacancyRetries() #if DEBUG @@ -271,8 +281,12 @@ extension TerminalSurface { /// Explicitly free the Ghostty runtime surface. Idempotent — safe to call /// before deinit; deinit will skip the free if already torn down. + /// + /// - Returns: A ticket that completes after the native free and callback + /// userdata releases, or the existing ticket when teardown already began. + @discardableResult @MainActor - public func teardownSurface() { + public func teardownSurface() -> TerminalSurfaceRuntimeTeardownTicket? { recordTeardownRequest(reason: "surface.teardown") markPortalLifecycleClosed(reason: "teardown") backgroundSurfaceStartSource = .normal @@ -286,6 +300,9 @@ extension TerminalSurface { self.manualIOContext = nil let teeLease = mobileByteTeeLease mobileByteTeeLease = nil + let runtimeOwnershipReservation = + runtimeSurfaceOwnershipReservation + self.runtimeSurfaceOwnershipReservation = nil byteTee.dropSurface(surfaceID: id) if let surfaceToFree { registry.unregisterRuntimeSurface(surfaceToFree, ownerId: id) @@ -295,7 +312,16 @@ extension TerminalSurface { callbackContext?.release() manualIOContext?.release() teeLease?.release() - return + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } + if closeRuntimeTeardownTicket == nil { + closeRuntimeTeardownTicket = + agentHibernationRuntimeTeardownTicket + } + return closeRuntimeTeardownTicket } #if DEBUG @@ -304,15 +330,25 @@ extension TerminalSurface { callbackContext?.release() manualIOContext?.release() teeLease?.release() - return + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } + return closeRuntimeTeardownTicket } #endif + guard let runtimeOwnershipReservation else { + preconditionFailure( + "A native terminal surface requires bounded runtime ownership" + ) + } #if DEBUG if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting { // Transport manualIOContext and teeLease through the request too: // the coordinator releases all callback userdata only after the // native free, which is what joins ghostty's IO threads. - runtimeTeardown.enqueueRuntimeTeardown( + let ticket = runtimeTeardown.enqueueRuntimeTeardown( id: id, workspaceId: tabId, reason: "teardown", @@ -320,21 +356,53 @@ extension TerminalSurface { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, + runtimeOwnershipReservation: runtimeOwnershipReservation, freeSurface: freeSurface ) - return + guard let ticket else { + preconditionFailure( + "Transferred runtime ownership must remain admitted" + ) + } + closeRuntimeTeardownTicket = ticket + return ticket } #endif - runtimeTeardown.enqueueRuntimeTeardown( + let ticket = runtimeTeardown.enqueueRuntimeTeardown( id: id, workspaceId: tabId, reason: "teardown", surface: surfaceToFree, callbackContext: callbackContext, manualIOContext: manualIOContext, - byteTeeLease: teeLease + byteTeeLease: teeLease, + runtimeOwnershipReservation: runtimeOwnershipReservation ) + guard let ticket else { + preconditionFailure( + "Transferred runtime ownership must remain admitted" + ) + } + closeRuntimeTeardownTicket = ticket + return ticket + } + + /// Waits for an explicit close teardown to release its native surface. + /// + /// - Parameter timeout: Maximum wait, or `nil` for an event-driven wait. + /// - Returns: `true` when no close teardown is pending or it completed. + @MainActor + public func waitForCloseRuntimeTeardown(timeout: Duration?) async -> Bool { + guard let ticket = closeRuntimeTeardownTicket else { return true } + let completed = await ticket.wait(timeout: timeout) + if completed, closeRuntimeTeardownTicket?.id == ticket.id { + closeRuntimeTeardownTicket = nil + } + if completed, agentHibernationRuntimeTeardownTicket?.id == ticket.id { + agentHibernationRuntimeTeardownTicket = nil + } + return completed } /// Frees the runtime surface while keeping the model alive for an @@ -361,6 +429,7 @@ extension TerminalSurface { advanceTerminalLifecycleForRuntimeReplacement() } runtimeSurfaceSuspendedForAgentHibernation = true + cancelRuntimeSurfaceCreationAfterAdmissionRecovery() backgroundSurfaceStartQueued = false backgroundSurfaceStartSource = .normal cancelAgentCommandShimInstallLifecycle() @@ -373,6 +442,9 @@ extension TerminalSurface { self.manualIOContext = nil let teeLease = mobileByteTeeLease mobileByteTeeLease = nil + let runtimeOwnershipReservation = + runtimeSurfaceOwnershipReservation + self.runtimeSurfaceOwnershipReservation = nil byteTee.dropSurface(surfaceID: id) if let surfaceToFree { @@ -394,9 +466,23 @@ extension TerminalSurface { callbackContext?.release() manualIOContext?.release() teeLease?.release() + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } return true } + guard let runtimeOwnershipReservation else { + runtimeTeardown.cancelIsolatedHibernationTeardown( + teardownReservation + ) + preconditionFailure( + "A native terminal surface requires bounded runtime ownership" + ) + } + #if DEBUG logDebugEvent( "surface.lifecycle.hibernate surface=\(id.uuidString.prefix(5)) " + @@ -417,10 +503,15 @@ extension TerminalSurface { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, + runtimeOwnershipReservation: runtimeOwnershipReservation, executionLane: .isolatedHibernation, isolatedHibernationReservation: teardownReservation, freeSurface: freeSurface ) + precondition( + agentHibernationRuntimeTeardownTicket != nil, + "Transferred runtime ownership must remain admitted" + ) return true } #endif @@ -433,9 +524,14 @@ extension TerminalSurface { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, + runtimeOwnershipReservation: runtimeOwnershipReservation, executionLane: .isolatedHibernation, isolatedHibernationReservation: teardownReservation ) + precondition( + agentHibernationRuntimeTeardownTicket != nil, + "Transferred runtime ownership must remain admitted" + ) return true } @@ -648,8 +744,249 @@ extension TerminalSurface { createSurface(for: view, source: source) } + @MainActor + private func runtimeSurfaceOwnershipRecovery( + teardownCoordinator: TerminalSurfaceRuntimeTeardownCoordinator + ) -> TerminalSurfaceRuntimeOwnershipRecovery { + { [weak self, teardownCoordinator] reservation in + guard let self else { + teardownCoordinator.cancelRuntimeSurfaceOwnership( + reservation + ) + return + } + self.resumeRuntimeSurfaceCreationAfterAdmissionRecovery( + reservation: reservation + ) + } + } + + @MainActor + private func runtimeSurfaceOwnershipRecoveryFailure( + generation: UInt64 + ) + -> TerminalSurfaceRuntimeOwnershipRecoveryFailure { + { [weak self] in + guard let self, + self.runtimeSurfaceAdmissionCreationGeneration == generation, + self.surface == nil, + self.runtimeSurfaceAdmissionDeferredCreationSource != nil else { + return + } + self.failRuntimeSurfaceCreationForTeardownCapacity( + preservingRecoveryOwner: true + ) + } + } + + @MainActor + private func reserveRuntimeSurfaceOwnershipForCreation( + view: any TerminalSurfaceNativeViewing, + source: RuntimeSurfaceCreationSource + ) -> TerminalSurfaceRuntimeOwnershipReservation? { + precondition(runtimeSurfaceAdmissionCreationGeneration < UInt64.max) + runtimeSurfaceAdmissionCreationGeneration += 1 + let creationGeneration = runtimeSurfaceAdmissionCreationGeneration + runtimeSurfaceAdmissionDeferredCreationSource = + ( + runtimeSurfaceAdmissionDeferredCreationSource + ?? source + ).promoted(with: source) + runtimeSurfaceAdmissionDeferredCreationView = view + let teardownCoordinator = runtimeTeardown + let admissionResult = + teardownCoordinator.reserveRuntimeSurfaceOwnership( + recoveryID: id, + onRecovery: runtimeSurfaceOwnershipRecovery( + teardownCoordinator: teardownCoordinator + ), + onFailure: runtimeSurfaceOwnershipRecoveryFailure( + generation: creationGeneration + ) + ) + switch admissionResult { + case .reserved(let reservation): + teardownCoordinator + .cancelRuntimeSurfaceOwnershipRecoveryOverflow(surfaceID: id) + runtimeSurfaceAdmissionOverflowSequence = nil + runtimeSurfaceAdmissionDeferredCreationSource = nil + runtimeSurfaceAdmissionDeferredCreationView = nil + return reservation + case .deferred: + teardownCoordinator + .cancelRuntimeSurfaceOwnershipRecoveryOverflow(surfaceID: id) + runtimeSurfaceAdmissionOverflowSequence = nil + return nil + case .rejected: + let overflowRegistration = + teardownCoordinator + .registerRuntimeSurfaceOwnershipRecoveryOverflow( + surfaceID: id, + surface: self + ) + switch overflowRegistration { + case .registered(let sequence), .updated(let sequence): + runtimeSurfaceAdmissionOverflowSequence = sequence + case .rejected: + failRuntimeSurfaceCreationForTeardownCapacity() + } + return nil + case .closeTeardownStalled: + retainRuntimeSurfaceCreationUntilTeardownRecovers() + return nil + } + } + + @MainActor + func retryRuntimeSurfaceCreationAfterAdmissionOverflow( + capacityReservation: + TerminalSurfaceRuntimeOwnershipRecoveryCapacityReservation + ) { + guard runtimeSurfaceAdmissionOverflowSequence != nil, + allowsRuntimeSurfaceCreation(), + surface == nil, + runtimeSurfaceAdmissionDeferredCreationSource != nil else { + runtimeTeardown + .cancelRuntimeSurfaceOwnershipRecoveryOverflow(surfaceID: id) + runtimeSurfaceAdmissionOverflowSequence = nil + runtimeSurfaceAdmissionDeferredCreationSource = nil + runtimeSurfaceAdmissionDeferredCreationView = nil + runtimeTeardown + .cancelRuntimeSurfaceOwnershipRecoveryCapacity( + capacityReservation + ) + return + } + let teardownCoordinator = runtimeTeardown + let admissionResult = + teardownCoordinator.reserveRuntimeSurfaceOwnership( + recoveryID: id, + onRecovery: runtimeSurfaceOwnershipRecovery( + teardownCoordinator: teardownCoordinator + ), + onFailure: runtimeSurfaceOwnershipRecoveryFailure( + generation: runtimeSurfaceAdmissionCreationGeneration + ), + capacityReservation: capacityReservation + ) + switch admissionResult { + case .reserved(let reservation): + teardownCoordinator + .cancelRuntimeSurfaceOwnershipRecoveryOverflow(surfaceID: id) + runtimeSurfaceAdmissionOverflowSequence = nil + resumeRuntimeSurfaceCreationAfterAdmissionRecovery( + reservation: reservation + ) + case .deferred: + teardownCoordinator + .cancelRuntimeSurfaceOwnershipRecoveryOverflow(surfaceID: id) + runtimeSurfaceAdmissionOverflowSequence = nil + case .rejected: + teardownCoordinator + .requestRuntimeSurfaceOwnershipRecoveryRescan() + case .closeTeardownStalled: + failRuntimeSurfaceCreationForTeardownCapacity( + preservingRecoveryOwner: true + ) + } + } + + @MainActor + private func resumeRuntimeSurfaceCreationAfterAdmissionRecovery( + reservation: TerminalSurfaceRuntimeOwnershipReservation + ) { + let source = + runtimeSurfaceAdmissionDeferredCreationSource + ?? .normal + let view = + runtimeSurfaceAdmissionDeferredCreationView + ?? attachedView + ?? surfaceView + runtimeSurfaceAdmissionDeferredCreationSource = nil + runtimeSurfaceAdmissionDeferredCreationView = nil + if requiresRestoreSpawnPacing, source == .scheduledRestore { + // The failed attempt already consumed its prior restore cadence + // slot. Return admission ownership and use the shared normal + // entrypoint to queue a fresh paced slot before retrying Ghostty. + runtimeTeardown.cancelRuntimeSurfaceOwnership(reservation) + createSurface(for: view) + return + } + createSurface( + for: view, + source: source, + recoveredOwnershipReservation: reservation + ) + } + + private func cancelRuntimeSurfaceCreationAfterAdmissionRecovery() { + runtimeTeardown.cancelRuntimeSurfaceOwnershipRecovery(id) + runtimeTeardown + .cancelRuntimeSurfaceOwnershipRecoveryOverflow(surfaceID: id) + precondition(runtimeSurfaceAdmissionCreationGeneration < UInt64.max) + runtimeSurfaceAdmissionCreationGeneration += 1 + runtimeSurfaceAdmissionOverflowSequence = nil + runtimeSurfaceAdmissionDeferredCreationSource = nil + runtimeSurfaceAdmissionDeferredCreationView = nil + } + + @MainActor + private func retainRuntimeSurfaceCreationUntilTeardownRecovers() { + let registration = runtimeTeardown + .registerRuntimeSurfaceOwnershipRecoveryOverflow( + surfaceID: id, + surface: self + ) + switch registration { + case .registered(let sequence), .updated(let sequence): + runtimeSurfaceAdmissionOverflowSequence = sequence + failRuntimeSurfaceCreationForTeardownCapacity( + preservingRecoveryOwner: true + ) + case .rejected: + failRuntimeSurfaceCreationForTeardownCapacity() + } + } + + @MainActor + func failRuntimeSurfaceCreationForTeardownCapacity( + preservingRecoveryOwner: Bool = false + ) { + if !preservingRecoveryOwner { + cancelRuntimeSurfaceCreationAfterAdmissionRecovery() + } + paneHost.showRuntimeSurfaceCreationFailure( + message: String( + localized: "terminal.surface.runtimeCreation.capacityExceeded", + defaultValue: + "Unable to start this terminal because too many terminal sessions are still closing." + ) + ) + } + @MainActor func createSurface(for view: any TerminalSurfaceNativeViewing, source: RuntimeSurfaceCreationSource) { + createSurface( + for: view, + source: source, + recoveredOwnershipReservation: nil + ) + } + + @MainActor + private func createSurface( + for view: any TerminalSurfaceNativeViewing, + source: RuntimeSurfaceCreationSource, + recoveredOwnershipReservation: TerminalSurfaceRuntimeOwnershipReservation? + ) { + var recoveredOwnershipReservation = recoveredOwnershipReservation + defer { + if let recoveredOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + recoveredOwnershipReservation + ) + } + } guard allowsRuntimeSurfaceCreation() else { #if DEBUG logDebugEvent( @@ -662,6 +999,7 @@ extension TerminalSurface { #endif return } + guard surface == nil else { return } if deferRuntimeSurfaceCreationForConfigurationReload( view: view, source: source @@ -686,7 +1024,29 @@ extension TerminalSurface { Self.surfaceLog("createSurface start surface=\(id.uuidString) tab=\(tabId.uuidString) bounds=\(view.bounds) inWindow=\(view.window != nil) resources=\(resourcesDir) terminfo=\(terminfo) xdg=\(xdg) manpath=\(manpath)") #endif + let runtimeOwnershipReservation: TerminalSurfaceRuntimeOwnershipReservation + if let recoveredReservation = recoveredOwnershipReservation { + runtimeOwnershipReservation = recoveredReservation + recoveredOwnershipReservation = nil + } else if let reservation = reserveRuntimeSurfaceOwnershipForCreation( + view: view, + source: source + ) { + runtimeOwnershipReservation = reservation + } else { +#if DEBUG + logDebugEvent( + "ghostty.surface.create.failed reason=teardownAdmissionDegraded " + + "surface=\(id.uuidString)" + ) +#endif + return + } + guard let app = engine.runtimeApp else { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) #if DEBUG logDebugEvent("ghostty.surface.create.failed reason=appNotInitialized surface=\(id.uuidString)") #endif @@ -708,6 +1068,9 @@ extension TerminalSurface { let runtimeInitialInput = runtimeSurfaceCreation.runtimeInitialInput if surface == nil { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) invalidateRuntimeClipboardRequests(in: surfaceCallbackContext, completingNativeRequests: false) surfaceCallbackContext?.release() surfaceCallbackContext = nil @@ -732,6 +1095,8 @@ extension TerminalSurface { #endif return } + self.runtimeSurfaceOwnershipReservation = + runtimeOwnershipReservation guard let createdSurface = surface else { return } guard let surfaceCallbackContext else { preconditionFailure( @@ -753,6 +1118,7 @@ extension TerminalSurface { registry.registerRuntimeSurface(createdSurface, ownerId: id) cacheControllingTTYIdentity(for: createdSurface) recordRuntimeSurfaceCreation() + paneHost.clearRuntimeSurfaceCreationFailure() // Install the shared PTY tee so output consumers receive every byte // the read thread produces, in order, before the VT parser runs. // Paired iPhones consume these bytes via `terminal.bytes` events diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index e9a37fdac846..ca851ba3e37b 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -290,9 +290,21 @@ public final class TerminalSurface: Identifiable, ObservableObject { RuntimeSurfaceCreationSource? weak var configurationReloadDeferredRuntimeSurfaceView: (any TerminalSurfaceNativeViewing)? + var runtimeSurfaceAdmissionDeferredCreationSource: + RuntimeSurfaceCreationSource? + weak var runtimeSurfaceAdmissionDeferredCreationView: + (any TerminalSurfaceNativeViewing)? + var runtimeSurfaceAdmissionOverflowSequence: UInt64? + var runtimeSurfaceAdmissionCreationGeneration: UInt64 = 0 var requiresRestoreSpawnPacing = false var startupRestoreAdmissionPhase = TerminalSurfaceStartupRestoreAdmissionPhase.unrestricted var runtimeSurfaceSuspendedForAgentHibernation = false + /// Bounded ownership of the live native surface. Transferred to the + /// teardown coordinator with the pointer and released only after native + /// free returns. + var runtimeSurfaceOwnershipReservation: + TerminalSurfaceRuntimeOwnershipReservation? + var closeRuntimeTeardownTicket: TerminalSurfaceRuntimeTeardownTicket? var agentHibernationRuntimeTeardownTicket: TerminalSurfaceRuntimeTeardownTicket? var agentHibernationRuntimeTeardownReservation: TerminalSurfaceRuntimeTeardownReservation? @@ -664,6 +676,9 @@ public final class TerminalSurface: Identifiable, ObservableObject { // mobileByteTeeLease, so teeLease is nil here and ?.release() no-ops. let teeLease = mobileByteTeeLease mobileByteTeeLease = nil + let runtimeOwnershipReservation = + runtimeSurfaceOwnershipReservation + runtimeSurfaceOwnershipReservation = nil // `dropSurface` is @MainActor but `deinit` is nonisolated, so hop to the // main actor with the surface id captured by value (no self capture). // Dropping by id only clears the registry/replay state; releasing @@ -692,6 +707,11 @@ public final class TerminalSurface: Identifiable, ObservableObject { callbackContext?.release() manualIOContext?.release() teeLease?.release() + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } return } @@ -701,10 +721,21 @@ public final class TerminalSurface: Identifiable, ObservableObject { callbackContext?.release() manualIOContext?.release() teeLease?.release() + if let runtimeOwnershipReservation { + runtimeTeardown.cancelRuntimeSurfaceOwnership( + runtimeOwnershipReservation + ) + } return } #endif + guard let runtimeOwnershipReservation else { + preconditionFailure( + "A native terminal surface requires bounded runtime ownership" + ) + } + #if DEBUG logDebugEvent( "surface.lifecycle.deinit.begin surface=\(id.uuidString.prefix(5)) " + @@ -720,7 +751,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { // the coordinator's deferred free runs. #if DEBUG if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting { - runtimeTeardown.enqueueRuntimeTeardown( + let ticket = runtimeTeardown.enqueueRuntimeTeardown( id: id, workspaceId: tabId, reason: "deinit", @@ -728,19 +759,29 @@ public final class TerminalSurface: Identifiable, ObservableObject { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, + runtimeOwnershipReservation: runtimeOwnershipReservation, freeSurface: freeSurface ) + precondition( + ticket != nil, + "Transferred runtime ownership must remain admitted" + ) return } #endif - runtimeTeardown.enqueueRuntimeTeardown( + let ticket = runtimeTeardown.enqueueRuntimeTeardown( id: id, workspaceId: tabId, reason: "deinit", surface: surfaceToFree, callbackContext: callbackContext, manualIOContext: manualIOContext, - byteTeeLease: teeLease + byteTeeLease: teeLease, + runtimeOwnershipReservation: runtimeOwnershipReservation + ) + precondition( + ticket != nil, + "Transferred runtime ownership must remain admitted" ) } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeSurfaceRegistry.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeSurfaceRegistry.swift index aa8ad0c33ed2..5fc9165aa5e6 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeSurfaceRegistry.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeSurfaceRegistry.swift @@ -1,10 +1,18 @@ import Foundation import GhosttyKit import CmuxTerminalCore +import os @testable import CmuxTerminal final class FakeSurfaceRegistry: @unchecked Sendable, TerminalSurfaceRegistering { private let backing = TerminalSurfaceRegistry() + private let allSurfacesCallCountLock = OSAllocatedUnfairLock( + initialState: 0 + ) + + var allSurfacesCallCount: Int { + allSurfacesCallCountLock.withLock { $0 } + } var topologyGeneration: UInt64 { backing.topologyGeneration } func register( @@ -72,6 +80,7 @@ final class FakeSurfaceRegistry: @unchecked Sendable, TerminalSurfaceRegistering backing.updateFocusPlacement(id: id, placement) } func allSurfaces() -> [any TerminalSurfacing] { - backing.allSurfaces() + allSurfacesCallCountLock.withLock { $0 += 1 } + return backing.allSurfaces() } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTeeLease.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTeeLease.swift index fb7e892afeec..eab05fde9868 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTeeLease.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTeeLease.swift @@ -1,5 +1,13 @@ @testable import CmuxTerminal final class FakeTerminalByteTeeLease: TerminalByteTeeLease { - func release() {} + private let onRelease: @Sendable () -> Void + + init(onRelease: @escaping @Sendable () -> Void = {}) { + self.onRelease = onRelease + } + + func release() { + onRelease() + } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalSurfacePaneHost.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalSurfacePaneHost.swift index c357104def55..805a0a028b53 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalSurfacePaneHost.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalSurfacePaneHost.swift @@ -7,12 +7,20 @@ final class FakeTerminalSurfacePaneHost: NSView, TerminalSurfacePaneHosting { private let attachesThroughSurfaceModel: Bool private let onAttach: (() -> Void)? private(set) var explicitInputCount = 0 + private(set) var runtimeSurfaceCreationFailureMessages: [String] = [] + private(set) var activeRuntimeSurfaceCreationFailureMessage: String? + let runtimeSurfaceCreationFailures: AsyncStream + private let runtimeSurfaceCreationFailureContinuation: + AsyncStream.Continuation init( surfaceView: FakeTerminalSurfaceNativeView, attachesThroughSurfaceModel: Bool = false, onAttach: (() -> Void)? = nil ) { + (runtimeSurfaceCreationFailures, + runtimeSurfaceCreationFailureContinuation) = + AsyncStream.makeStream(of: String.self) self.surfaceView = surfaceView self.attachesThroughSurfaceModel = attachesThroughSurfaceModel self.onAttach = onAttach @@ -39,6 +47,16 @@ final class FakeTerminalSurfacePaneHost: NSView, TerminalSurfacePaneHosting { func syncKeyStateIndicator(text: String?) {} func setMobileViewportBorder(size: CGSize?, drawRight: Bool, drawBottom: Bool) {} + func showRuntimeSurfaceCreationFailure(message: String) { + activeRuntimeSurfaceCreationFailureMessage = message + runtimeSurfaceCreationFailureMessages.append(message) + runtimeSurfaceCreationFailureContinuation.yield(message) + } + + func clearRuntimeSurfaceCreationFailure() { + activeRuntimeSurfaceCreationFailureMessage = nil + } + func terminalSurfaceDidReceiveExplicitInput() { explicitInputCount += 1 } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/ManualTerminalSurfaceRuntimeTeardownClock.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/ManualTerminalSurfaceRuntimeTeardownClock.swift new file mode 100644 index 000000000000..c2562933cc71 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/ManualTerminalSurfaceRuntimeTeardownClock.swift @@ -0,0 +1,120 @@ +import Foundation +import os + +struct ManualTerminalSurfaceRuntimeTeardownInstant: + InstantProtocol +{ + typealias Duration = Swift.Duration + + let offset: Duration + + func advanced(by duration: Duration) -> Self { + Self(offset: offset + duration) + } + + func duration(to other: Self) -> Duration { + other.offset - offset + } + + static func < ( + lhs: ManualTerminalSurfaceRuntimeTeardownInstant, + rhs: ManualTerminalSurfaceRuntimeTeardownInstant + ) -> Bool { + lhs.offset < rhs.offset + } +} + +/// Manual clock whose positive registration stream is the test's watchdog +/// readiness signal. Advancing one registration resumes only that watchdog. +final class ManualTerminalSurfaceRuntimeTeardownClock: + Clock, + @unchecked Sendable +{ + typealias Duration = Swift.Duration + typealias Instant = ManualTerminalSurfaceRuntimeTeardownInstant + + private struct Waiter { + let deadline: Instant + let continuation: CheckedContinuation + } + + private struct State { + var now = Instant(offset: .zero) + var nextID = 0 + var waiters: [Int: Waiter] = [:] + var cancelledIDs: Set = [] + } + + let registrations: AsyncStream + private let registrationContinuation: AsyncStream.Continuation + private let state = OSAllocatedUnfairLock(initialState: State()) + + init() { + (registrations, registrationContinuation) = + AsyncStream.makeStream(of: Int.self) + } + + var now: Instant { + state.withLock(\.now) + } + + var minimumResolution: Duration { + .nanoseconds(1) + } + + func sleep( + until deadline: Instant, + tolerance: Duration? + ) async throws { + try Task.checkCancellation() + let id = state.withLock { state in + let id = state.nextID + state.nextID += 1 + return id + } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let registered = state.withLock { state in + if state.cancelledIDs.remove(id) != nil { + return false + } + if deadline <= state.now { + return false + } + state.waiters[id] = Waiter( + deadline: deadline, + continuation: continuation + ) + return true + } + if registered { + registrationContinuation.yield(id) + } else if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + continuation.resume() + } + } + } onCancel: { + let waiter = state.withLock { state in + guard let waiter = state.waiters.removeValue(forKey: id) else { + state.cancelledIDs.insert(id) + return nil + } + return waiter + } + waiter?.continuation.resume(throwing: CancellationError()) + } + } + + func fire(_ id: Int) { + let waiter = state.withLock { state in + guard let waiter = state.waiters.removeValue(forKey: id) else { + return nil + } + state.now = max(state.now, waiter.deadline) + return waiter + } + waiter?.continuation.resume() + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingRestoreSpawnScheduler.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingRestoreSpawnScheduler.swift index c6440896fb4e..627bbe3116ea 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingRestoreSpawnScheduler.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingRestoreSpawnScheduler.swift @@ -5,13 +5,23 @@ import Foundation final class RecordingRestoreSpawnScheduler: TerminalSurfaceRuntimeSpawnScheduling { private(set) var scheduledSurfaceIds: [UUID] = [] private var scheduledOperations: [@MainActor () -> Void] = [] + private let scheduledEvents = AsyncStream.makeStream() func scheduleRestoredSurfaceSpawn(surfaceId: UUID, operation: @escaping @MainActor () -> Void) { scheduledSurfaceIds.append(surfaceId) scheduledOperations.append(operation) + scheduledEvents.continuation.yield(surfaceId) } func runScheduledOperation(at index: Int = 0) { scheduledOperations[index]() } + + func waitForScheduledCount(_ count: Int) async { + guard scheduledSurfaceIds.count < count else { return } + for await _ in scheduledEvents.stream { + guard scheduledSurfaceIds.count >= count else { continue } + return + } + } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRestoreSpawnSchedulerTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRestoreSpawnSchedulerTests.swift index 90aeec28edb8..cc124abc2502 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRestoreSpawnSchedulerTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRestoreSpawnSchedulerTests.swift @@ -1,6 +1,8 @@ -import Foundation import AppKit +import Dispatch +import Foundation import GhosttyKit +import os import Testing import CmuxTerminalCore @testable import CmuxTerminal @@ -189,6 +191,1390 @@ import CmuxTerminalCore #expect(surface.runtimeSurfacePointer == nil) } + @Test func runtimeCreationRetriesAfterTeardownAdmissionRecovers() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 4 + ) + let retainedSurfaces = (0..<2).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + defer { for retainedSurface in retainedSurfaces { retainedSurface.deallocate() } } + let runtimeReservations = try (0...makeStream() + let releaseFrees = DispatchSemaphore(value: 0) + defer { + releaseFrees.signal() + releaseFrees.signal() + freeStarted.continuation.finish() + } + + let tickets = try retainedSurfaces.enumerated().map { index, retainedSurface in + let ticket = coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.creationRetry.\(index)", + surface: retainedSurface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservations[index], + freeSurface: { _ in + freeStarted.continuation.yield() + releaseFrees.wait() + } + ) + return try #require(ticket) + } + var freeStartedIterator = freeStarted.stream.makeAsyncIterator() + _ = await freeStartedIterator.next() + _ = await freeStartedIterator.next() + + let degradationDeadline = ContinuousClock.now + .seconds(1) + while await !coordinator.debugCloseTeardownDegraded, + ContinuousClock.now < degradationDeadline { + await Task.yield() + } + #expect(await coordinator.debugCloseTeardownDegraded) + + let nativeView = FakeTerminalSurfaceNativeView( + frame: NSRect(x: 0, y: 0, width: 800, height: 600) + ) + let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView) + let surface = makeSurface( + runtimeSpawnPolicy: .immediate, + scheduler: RecordingRestoreSpawnScheduler(), + nativeView: nativeView, + paneHost: paneHost, + runtimeTeardown: coordinator + ) + surface.claudeCommandShimInstallCompleted = true + + surface.createSurface(for: nativeView) + #expect(surface.debugRuntimeSurfaceCreateAttemptCountForTesting() == 1) + + releaseFrees.signal() + let retryDeadline = ContinuousClock.now + .seconds(1) + while surface.debugRuntimeSurfaceCreateAttemptCountForTesting() < 2, + ContinuousClock.now < retryDeadline { + await Task.yield() + } + + #expect( + surface.debugRuntimeSurfaceCreateAttemptCountForTesting() == 2, + "surface creation was not retried after native ownership admission recovered" + ) + releaseFrees.signal() + for ticket in tickets { + #expect(await ticket.wait(timeout: .seconds(1))) + } + } + + @Test func restoredCreationReentersPacingAfterAdmissionRecovers() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 4 + ) + let retainedSurfaces = (0..<2).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + defer { + for retainedSurface in retainedSurfaces { + retainedSurface.deallocate() + } + } + let runtimeReservations = try (0...makeStream() + let releaseFrees = DispatchSemaphore(value: 0) + defer { + releaseFrees.signal() + releaseFrees.signal() + freeStarted.continuation.finish() + } + + let tickets = try retainedSurfaces.enumerated().map { + index, + retainedSurface in + let ticket = coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.pacedCreationRetry.\(index)", + surface: retainedSurface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservations[index], + freeSurface: { _ in + freeStarted.continuation.yield() + releaseFrees.wait() + } + ) + return try #require(ticket) + } + var freeStartedIterator = freeStarted.stream.makeAsyncIterator() + _ = await freeStartedIterator.next() + _ = await freeStartedIterator.next() + + let degradationDeadline = ContinuousClock.now + .seconds(1) + while await !coordinator.debugCloseTeardownDegraded, + ContinuousClock.now < degradationDeadline { + await Task.yield() + } + #expect(await coordinator.debugCloseTeardownDegraded) + + let nativeView = FakeTerminalSurfaceNativeView( + frame: NSRect(x: 0, y: 0, width: 800, height: 600) + ) + let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView) + let scheduler = RecordingRestoreSpawnScheduler() + let surface = makeSurface( + scheduler: scheduler, + nativeView: nativeView, + paneHost: paneHost, + runtimeTeardown: coordinator + ) + surface.scheduleHeadlessRuntimeStartIfNeeded( + reason: "test-admission-recovery-pacing" + ) + defer { surface.closeHeadlessStartupWindowIfNeeded() } + surface.attachedView = nativeView + surface.claudeCommandShimInstallCompleted = true + + surface.createSurface(for: nativeView) + #expect(scheduler.scheduledSurfaceIds == [surface.id]) + scheduler.runScheduledOperation() + #expect(surface.debugRuntimeSurfaceCreateAttemptCountForTesting() == 1) + + releaseFrees.signal() + let recoveryDeadline = ContinuousClock.now + .seconds(1) + while scheduler.scheduledSurfaceIds.count < 2, + surface.debugRuntimeSurfaceCreateAttemptCountForTesting() < 2, + ContinuousClock.now < recoveryDeadline { + await Task.yield() + } + + #expect(scheduler.scheduledSurfaceIds == [surface.id, surface.id]) + #expect( + surface.debugRuntimeSurfaceCreateAttemptCountForTesting() == 1, + "admission recovery bypassed restored-surface spawn pacing" + ) + releaseFrees.signal() + for ticket in tickets { + #expect(await ticket.wait(timeout: .seconds(1))) + } + } + + @Test func overflowedCreationRetriesInAdmissionOrder() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 2 + ) + let saturation = try saturateRuntimeOwnershipRecovery(coordinator) + defer { releaseRuntimeOwnershipSaturation(saturation, from: coordinator) } + let registry = FakeSurfaceRegistry() + let scheduler = RecordingRestoreSpawnScheduler() + let fixtures = (0..<2).map { _ in + makeSurfaceFixture( + registry: registry, + scheduler: scheduler, + runtimeTeardown: coordinator + ) + } + + for (index, fixture) in fixtures.enumerated() { + fixture.surface.createSurface(for: fixture.nativeView) + scheduler.runScheduledOperation(at: index) + try #require( + fixture.surface.runtimeSurfaceAdmissionDeferredCreationSource + == .scheduledRestore + ) + } + + coordinator.cancelRuntimeSurfaceOwnership(saturation.owners[0]) + await scheduler.waitForScheduledCount(4) + + #expect( + scheduler.scheduledSurfaceIds == [ + fixtures[0].surface.id, + fixtures[1].surface.id, + fixtures[0].surface.id, + fixtures[1].surface.id, + ] + ) + #expect( + registry.allSurfacesCallCount == 0, + "overflow recovery must not scan the full surface registry" + ) + } + + @Test func newCreationStaysBehindOlderOverflowWhenCapacityReturns() throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 2 + ) + let owners = try (0..<2).map { _ in + try #require(coordinator.reserveRuntimeSurfaceOwnership()) + } + let registry = FakeSurfaceRegistry() + let scheduler = RecordingRestoreSpawnScheduler() + let fixtures = (0..<4).map { _ in + makeSurfaceFixture( + registry: registry, + scheduler: scheduler, + runtimeTeardown: coordinator + ) + } + var firstOwnerReleased = false + defer { + for fixture in fixtures { + fixture.surface.beginPortalCloseLifecycle( + reason: "test.overflowAdmissionOrder" + ) + } + if !firstOwnerReleased { + coordinator.cancelRuntimeSurfaceOwnership(owners[0]) + } + coordinator.cancelRuntimeSurfaceOwnership(owners[1]) + } + + for (index, fixture) in fixtures.prefix(3).enumerated() { + fixture.surface.createSurface(for: fixture.nativeView) + scheduler.runScheduledOperation(at: index) + } + coordinator.cancelRuntimeSurfaceOwnershipRecovery(fixtures[0].surface.id) + coordinator.cancelRuntimeSurfaceOwnershipRecovery(fixtures[1].surface.id) + let olderOverflow = + coordinator.debugRuntimeSurfaceOwnershipRecoveryOverflowSnapshot + #expect(olderOverflow.headID == fixtures[2].surface.id) + #expect(olderOverflow.tailID == fixtures[2].surface.id) + + coordinator.cancelRuntimeSurfaceOwnership(owners[0]) + firstOwnerReleased = true + fixtures[3].surface.createSurface(for: fixtures[3].nativeView) + scheduler.runScheduledOperation(at: 3) + + let orderedOverflow = + coordinator.debugRuntimeSurfaceOwnershipRecoveryOverflowSnapshot + #expect(orderedOverflow.entryCount == 2) + #expect(orderedOverflow.linkedNodeCount == 2) + #expect(orderedOverflow.headID == fixtures[2].surface.id) + #expect(orderedOverflow.tailID == fixtures[3].surface.id) + } + + @Test func overflowRecoveryWaitsForEveryOlderPrimaryRequest() async throws { + let ownerCount = 4 + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: ownerCount + ) + let owners = try (0...makeStream() + let finishFreeStarted = DispatchSemaphore(value: 0) + let releaseFinishFree = DispatchSemaphore(value: 0) + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 2, + closeTeardownTimeout: .seconds(5), + closeTeardownClock: clock, + closeTeardownStalledObserver: { slot in + stalledSlots.continuation.yield(slot) + } + ) + let pointers = (0..<2).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + let freeStarted = AsyncStream.makeStream() + let releaseFrees = (0..<2).map { _ in DispatchSemaphore(value: 0) } + let freedPointerBits = OSAllocatedUnfairLock(initialState: [UInt]()) + defer { + for releaseFree in releaseFrees { + releaseFree.signal() + } + for pointer in pointers { + pointer.deallocate() + } + freeStarted.continuation.finish() + stalledSlots.continuation.finish() + releaseFinishFree.signal() + } + let reservations = try (0..<2).map { _ in + try #require(coordinator.reserveRuntimeSurfaceOwnership()) + } + let tickets = try pointers.enumerated().map { index, pointer in + let byteTeeLease: (any TerminalByteTeeLease)? = index == 0 + ? FakeTerminalByteTeeLease { + finishFreeStarted.signal() + releaseFinishFree.wait() + } + : nil + try #require( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.stalledClose.\(index)", + surface: pointer, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: byteTeeLease, + runtimeOwnershipReservation: reservations[index], + freeSurface: { pointer in + freeStarted.continuation.yield(index) + releaseFrees[index].wait() + freedPointerBits.withLock { + $0.append(UInt(bitPattern: pointer)) + } + } + ) + ) + } + var freeStartedIterator = freeStarted.stream.makeAsyncIterator() + _ = try #require(await freeStartedIterator.next()) + _ = try #require(await freeStartedIterator.next()) + var registrationIterator = clock.registrations.makeAsyncIterator() + let firstWatchdog = try #require(await registrationIterator.next()) + let secondWatchdog = try #require(await registrationIterator.next()) + + let registry = FakeSurfaceRegistry() + let scheduler = RecordingRestoreSpawnScheduler() + let deferredFixtures = (0..<3).map { _ in + makeSurfaceFixture( + registry: registry, + scheduler: scheduler, + runtimeTeardown: coordinator + ) + } + for (index, fixture) in deferredFixtures.enumerated() { + fixture.surface.createSurface(for: fixture.nativeView) + scheduler.runScheduledOperation(at: index) + } + + var stalledSlotIterator = stalledSlots.stream.makeAsyncIterator() + clock.fire(firstWatchdog) + _ = try #require(await stalledSlotIterator.next()) + #expect( + deferredFixtures.allSatisfy { + $0.paneHost.runtimeSurfaceCreationFailureMessages.isEmpty + } + ) + + var failureIterators = deferredFixtures.map { + $0.paneHost.runtimeSurfaceCreationFailures.makeAsyncIterator() + } + clock.fire(secondWatchdog) + _ = try #require(await stalledSlotIterator.next()) + let expectedMessage = String( + localized: "terminal.surface.runtimeCreation.capacityExceeded", + defaultValue: + "Unable to start this terminal because too many terminal sessions are still closing." + ) + for index in failureIterators.indices { + #expect( + try #require(await failureIterators[index].next()) + == expectedMessage + ) + #expect( + deferredFixtures[index].surface + .runtimeSurfaceAdmissionDeferredCreationSource != nil + ) + } + #expect( + coordinator.debugRuntimeSurfaceOwnershipRecoveryOverflowSnapshot + .entryCount == 1 + ) + + let rejectedAfterStall = makeSurfaceFixture( + registry: registry, + scheduler: scheduler, + runtimeTeardown: coordinator + ) + var postStallFailureIterator = rejectedAfterStall.paneHost + .runtimeSurfaceCreationFailures.makeAsyncIterator() + rejectedAfterStall.surface.createSurface( + for: rejectedAfterStall.nativeView + ) + scheduler.runScheduledOperation(at: deferredFixtures.count) + #expect( + try #require(await postStallFailureIterator.next()) + == expectedMessage + ) + #expect( + rejectedAfterStall.surface + .runtimeSurfaceAdmissionDeferredCreationSource != nil + ) + + deferredFixtures[0].surface.createSurface( + for: deferredFixtures[0].nativeView + ) + scheduler.runScheduledOperation(at: deferredFixtures.count + 1) + #expect( + deferredFixtures[0].paneHost.runtimeSurfaceCreationFailureMessages + == [expectedMessage], + "repeated creation discarded its existing primary recovery owner" + ) + let firstDeferredAttemptCount = deferredFixtures[0].surface + .debugRuntimeSurfaceCreateAttemptCountForTesting() + + let stalledStateAfterFree = Task.detached { + finishFreeStarted.wait() + let stalled = coordinator.debugCloseTeardownAllStalled + releaseFinishFree.signal() + return stalled + } + releaseFrees[0].signal() + let remainedAllStalled = await stalledStateAfterFree.value + #expect( + !remainedAllStalled, + "returned close worker retained the all-stalled admission failure" + ) + #expect(await tickets[0].wait(timeout: nil)) + await waitForMainActorQueueBarrier() + #expect( + deferredFixtures[0].surface + .debugRuntimeSurfaceCreateAttemptCountForTesting() + == firstDeferredAttemptCount + 1 + ) + #expect( + deferredFixtures[0].surface + .runtimeSurfaceAdmissionDeferredCreationSource == nil + ) + #expect( + freedPointerBits.withLock { $0 } + == [UInt(bitPattern: pointers[0])] + ) + let recoveredReservation = try #require( + coordinator.reserveRuntimeSurfaceOwnership() + ) + coordinator.cancelRuntimeSurfaceOwnership(recoveredReservation) + #expect(!coordinator.debugCloseTeardownAllStalled) + + releaseFrees[1].signal() + #expect(await tickets[1].wait(timeout: nil)) + #expect( + Set(freedPointerBits.withLock { $0 }) + == Set(pointers.map { UInt(bitPattern: $0) }) + ) + #expect(coordinator.debugRuntimeSurfaceOwnerCount == 0) + } + + @Test @MainActor func recoveredCloseWorkerCancelsQueuedCapacityFailures() async throws { + let clock = ManualTerminalSurfaceRuntimeTeardownClock() + let stalledSlots = AsyncStream.makeStream() + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 2, + closeTeardownTimeout: .seconds(5), + closeTeardownClock: clock, + closeTeardownStalledObserver: { slot in + stalledSlots.continuation.yield(slot) + } + ) + let pointers = (0..<2).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + let freeStarted = AsyncStream.makeStream() + let releaseFrees = (0..<2).map { _ in DispatchSemaphore(value: 0) } + defer { + for releaseFree in releaseFrees { + releaseFree.signal() + } + for pointer in pointers { + pointer.deallocate() + } + freeStarted.continuation.finish() + stalledSlots.continuation.finish() + } + let reservations = try (0..<2).map { _ in + try #require(coordinator.reserveRuntimeSurfaceOwnership()) + } + let tickets = try pointers.enumerated().map { index, pointer in + try #require( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.cancelStaleCapacityFailure.\(index)", + surface: pointer, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: reservations[index], + freeSurface: { _ in + freeStarted.continuation.yield(index) + releaseFrees[index].wait() + } + ) + ) + } + var freeStartedIterator = freeStarted.stream.makeAsyncIterator() + _ = try #require(await freeStartedIterator.next()) + _ = try #require(await freeStartedIterator.next()) + var registrationIterator = clock.registrations.makeAsyncIterator() + let firstWatchdog = try #require(await registrationIterator.next()) + let secondWatchdog = try #require(await registrationIterator.next()) + + let registry = FakeSurfaceRegistry() + let scheduler = RecordingRestoreSpawnScheduler() + let deferredFixtures = (0..<3).map { _ in + makeSurfaceFixture( + registry: registry, + scheduler: scheduler, + runtimeTeardown: coordinator + ) + } + for (index, fixture) in deferredFixtures.enumerated() { + fixture.surface.createSurface(for: fixture.nativeView) + scheduler.runScheduledOperation(at: index) + } + + var stalledSlotIterator = stalledSlots.stream.makeAsyncIterator() + clock.fire(firstWatchdog) + _ = try #require(await stalledSlotIterator.next()) + clock.fire(secondWatchdog) + _ = try #require(await stalledSlotIterator.next()) + + // Keep MainActor failure delivery queued while the coordinator actor + // receives the native-free completion and reopens admission. + let freeCompletion = DispatchSemaphore(value: 0) + Task.detached { + _ = await tickets[0].wait(timeout: nil) + freeCompletion.signal() + } + releaseFrees[0].signal() + freeCompletion.wait() + #expect(!coordinator.debugCloseTeardownAllStalled) + + await waitForMainActorQueueBarrier() + #expect( + deferredFixtures.allSatisfy { + $0.paneHost.runtimeSurfaceCreationFailureMessages.isEmpty + } + ) + + releaseFrees[1].signal() + #expect(await tickets[1].wait(timeout: nil)) + for fixture in deferredFixtures { + fixture.surface.beginPortalCloseLifecycle( + reason: "test.cancelStaleCapacityFailure.cleanup" + ) + } + } + + @Test func repeatedStalledRecoveryUsesLatestFailureDelivery() throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 2 + ) + let owners = try (0..<2).map { _ in + try #require(coordinator.reserveRuntimeSurfaceOwnership()) + } + let recoveryID = UUID() + var deliveredFailures: [String] = [] + #expect( + coordinator.reserveRuntimeSurfaceOwnership( + recoveryID: recoveryID, + onRecovery: { reservation in + coordinator.cancelRuntimeSurfaceOwnership(reservation) + }, + onFailure: { deliveredFailures.append("original") } + ) == .deferred + ) + let deliveries = coordinator.runtimeOwnershipAdmission + .failRecoveriesForAllStalledCloseTeardowns() + let delivery = try #require(deliveries.first) + + #expect( + coordinator.reserveRuntimeSurfaceOwnership( + recoveryID: recoveryID, + onRecovery: { reservation in + coordinator.cancelRuntimeSurfaceOwnership(reservation) + }, + onFailure: { deliveredFailures.append("latest") } + ) == .deferred + ) + delivery() + + #expect(deliveredFailures == ["latest"]) + coordinator.runtimeOwnershipAdmission + .completeStalledCloseRecoveryFailures([delivery]) + coordinator.cancelRuntimeSurfaceOwnershipRecovery(recoveryID) + coordinator.runtimeOwnershipAdmission.clearAllStalledCloseTeardowns() + for owner in owners { + coordinator.cancelRuntimeSurfaceOwnership(owner) + } + } + + @Test func repeatedStallReplacesQueuedFailureDeliveriesWithinCapacity() async throws { + let admission = TerminalSurfaceRuntimeOwnershipAdmission( + maximumOwnerCount: 2 + ) + let failureDrain = TerminalSurfaceRuntimeOwnershipRecoveryFailureDrain( + maximumFailureCount: 2, + admission: admission + ) + let owners = try (0..<2).map { _ in + try #require(admission.reserve()) + } + let recoveryIDs = (0..<2).map { _ in UUID() } + var failureCount = 0 + defer { + for recoveryID in recoveryIDs { + admission.cancelRecovery(recoveryID) + } + admission.clearAllStalledCloseTeardowns() + for owner in owners { + admission.release(owner) + } + } + + for recoveryID in recoveryIDs { + #expect( + admission.reserve( + recoveryID: recoveryID, + onRecovery: { reservation in + admission.release(reservation) + }, + onFailure: { failureCount += 1 } + ) == .deferred + ) + } + failureDrain.enqueue( + admission.failRecoveriesForAllStalledCloseTeardowns() + ) + + admission.clearAllStalledCloseTeardowns() + failureDrain.enqueue( + admission.failRecoveriesForAllStalledCloseTeardowns() + ) + await waitForMainActorQueueBarrier() + + #expect(failureCount == recoveryIDs.count) + } + + @Test func newRecoveryDefersBehindScheduledOlderGrant() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 2 + ) + let owners = try (0..<2).map { _ in + try #require(coordinator.reserveRuntimeSurfaceOwnership()) + } + let olderRecoveryIDs = (0..<2).map { _ in UUID() } + for recoveryID in olderRecoveryIDs { + #expect( + coordinator.reserveRuntimeSurfaceOwnership( + recoveryID: recoveryID, + onRecovery: { reservation in + coordinator.cancelRuntimeSurfaceOwnership(reservation) + } + ) == .deferred + ) + } + + coordinator.cancelRuntimeSurfaceOwnership(owners[0]) + coordinator.cancelRuntimeSurfaceOwnership(owners[1]) + let newestRecoveryID = UUID() + let newestResult = coordinator.reserveRuntimeSurfaceOwnership( + recoveryID: newestRecoveryID, + onRecovery: { reservation in + coordinator.cancelRuntimeSurfaceOwnership(reservation) + } + ) + if case .reserved(let reservation) = newestResult { + coordinator.cancelRuntimeSurfaceOwnership(reservation) + } + + #expect(newestResult == .deferred) + for recoveryID in olderRecoveryIDs + [newestRecoveryID] { + coordinator.cancelRuntimeSurfaceOwnershipRecovery(recoveryID) + } + await waitForMainActorQueueBarrier() + } + + @Test func completedFailureBatchRescansOverflowAndIgnoresStaleFailure() async throws { + let capacity = 34 + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: capacity + ) + let owners = try (0.. ( + surface: TerminalSurface, + nativeView: FakeTerminalSurfaceNativeView, + paneHost: FakeTerminalSurfacePaneHost + ) { + let nativeView = FakeTerminalSurfaceNativeView( + frame: NSRect(x: 0, y: 0, width: 800, height: 600) + ) + let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView) + let surface = makeSurface( + scheduler: scheduler, + nativeView: nativeView, + paneHost: paneHost, + registry: registry, + runtimeTeardown: runtimeTeardown + ) + surface.agentCommandShimInstallCompleted = true + return (surface, nativeView, paneHost) + } + + private func saturateRuntimeOwnershipRecovery( + _ coordinator: TerminalSurfaceRuntimeTeardownCoordinator, + count: Int = 2 + ) throws -> ( + owners: [TerminalSurfaceRuntimeOwnershipReservation], + recoveryIDs: [UUID] + ) { + let owners = try (0..]] = [:] + private let state = OSAllocatedUnfairLock(initialState: [UInt]()) + private let events: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + (events, continuation) = AsyncStream.makeStream(of: Int.self) + } func record(_ pointerBits: UInt) { - freed.append(pointerBits) - let count = freed.count - for waiter in continuations.removeValue(forKey: count) ?? [] { - waiter.resume() + let count = state.withLock { freed in + freed.append(pointerBits) + return freed.count } + continuation.yield(count) } /// Suspends until `count` frees have been recorded. func waitForFreeCount(_ count: Int) async { - guard freed.count < count else { return } - await withCheckedContinuation { continuation in - continuations[count, default: []].append(continuation) + guard state.withLock({ $0.count }) < count else { return } + for await recordedCount in events where recordedCount >= count { + return + } + } + + var freed: [UInt] { + state.withLock { $0 } + } +} + +/// Holds preferred-executor jobs until a test opens the gate. +private final class GatedTaskExecutor: TaskExecutor, @unchecked Sendable { + private struct State { + var isOpen = true + var jobs: [UnownedJob] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + func enqueue(_ job: consuming ExecutorJob) { + let job = UnownedJob(job) + let shouldRun = state.withLock { state in + guard !state.isOpen else { return true } + state.jobs.append(job) + return false + } + if shouldRun { + job.runSynchronously(on: asUnownedTaskExecutor()) + } + } + + func close() { + state.withLock { state in + precondition(state.isOpen) + precondition(state.jobs.isEmpty) + state.isOpen = false + } + } + + func open() { + let jobs = state.withLock { state in + guard !state.isOpen else { return [UnownedJob]() } + state.isOpen = true + let jobs = state.jobs + state.jobs.removeAll(keepingCapacity: true) + return jobs + } + for job in jobs { + job.runSynchronously(on: asUnownedTaskExecutor()) } } } @@ -67,6 +118,12 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec } } +private func requireTeardownTicket( + _ ticket: TerminalSurfaceRuntimeTeardownTicket? +) throws -> TerminalSurfaceRuntimeTeardownTicket { + try #require(ticket) +} + @Suite struct TerminalSurfaceRuntimeTeardownCoordinatorTests { @Test func ticketDistinguishesDeadlineFromEventualCompletion() async { let completion = TerminalSurfaceRuntimeTeardownCompletion() @@ -79,113 +136,1076 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec #expect(await ticket.wait(timeout: nil)) } - @Test func enqueuedTeardownInvokesInjectedFreeWithTheSamePointer() async { - let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + @Test func acceptedTeardownRetainsCoordinatorUntilTicketCompletion() async throws { + let executor = GatedTaskExecutor() + defer { executor.open() } let recorder = FreedSurfaceRecorder() let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) defer { surface.deallocate() } - let ticket = coordinator.enqueueRuntimeTeardown( - id: UUID(), - workspaceId: UUID(), - reason: "test", - surface: surface, - callbackContext: nil, - freeSurface: { pointer in - let bits = UInt(bitPattern: pointer) - Task { await recorder.record(bits) } + var accepted = try await withTaskExecutorPreference(executor) { + executor.close() + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let runtimeReservation = try #require( + coordinator.reserveRuntimeSurfaceOwnership() + ) + let ticket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.acceptedCoordinatorLifetime", + surface: surface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservation, + freeSurface: { pointer in + recorder.record(UInt(bitPattern: pointer)) + } + ) + ) + return (coordinator: Optional(coordinator), ticket: ticket) + } + weak var weakCoordinator = accepted.coordinator + + accepted.coordinator = nil + + try #require(weakCoordinator != nil) + executor.open() + await recorder.waitForFreeCount(1) + #expect(await accepted.ticket.wait(timeout: nil)) + #expect(recorder.freed == [UInt(bitPattern: surface)]) + } + + @Test func idleCoordinatorDoesNotRetainItself() { + var coordinator: TerminalSurfaceRuntimeTeardownCoordinator? = + TerminalSurfaceRuntimeTeardownCoordinator() + weak var weakCoordinator = coordinator + + coordinator = nil + + #expect(weakCoordinator == nil) + } + + @Test func stalledCloseFailuresYieldMainActorAfterBoundedBatch() async throws { + let batchCount = 32 + let failureCount = batchCount + 1 + let clock = ManualTerminalSurfaceRuntimeTeardownClock() + let stalledSlots = AsyncStream.makeStream() + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: failureCount, + closeTeardownTimeout: .seconds(5), + closeTeardownClock: clock, + closeTeardownStalledObserver: { slot in + stalledSlots.continuation.yield(slot) } ) + let pointers = (0..<2).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + let freeStarted = AsyncStream.makeStream() + let releaseFrees = (0..<2).map { _ in DispatchSemaphore(value: 0) } + let recordedFailureCount = OSAllocatedUnfairLock(initialState: 0) + let failureEvents = AsyncStream.makeStream() + let mainActorMarker = AsyncStream.makeStream() + defer { + for releaseFree in releaseFrees { + releaseFree.signal() + } + for pointer in pointers { + pointer.deallocate() + } + freeStarted.continuation.finish() + stalledSlots.continuation.finish() + failureEvents.continuation.finish() + mainActorMarker.continuation.finish() + } + + let reservations = try (0..<2).map { _ in + try #require(coordinator.reserveRuntimeSurfaceOwnership()) + } + let tickets = try pointers.enumerated().map { index, pointer in + try #require( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.stalledFailureBatch.\(index)", + surface: pointer, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: reservations[index], + freeSurface: { _ in + freeStarted.continuation.yield(index) + releaseFrees[index].wait() + } + ) + ) + } + var freeStartedIterator = freeStarted.stream.makeAsyncIterator() + _ = try #require(await freeStartedIterator.next()) + _ = try #require(await freeStartedIterator.next()) + var registrationIterator = clock.registrations.makeAsyncIterator() + let firstWatchdog = try #require(await registrationIterator.next()) + let secondWatchdog = try #require(await registrationIterator.next()) + + for _ in 0...makeStream() + let releaseWorkers = (0..() + while firstStartedIndices.count < 4 { + firstStartedIndices.insert( + try #require(await workerStartedIterator.next()) + ) + } + #expect(firstStartedIndices == Set(0..<4)) + #expect(await coordinator.reserveIsolatedHibernationTeardown() == nil) + + let fifthTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.dedicatedWorker.4", + surface: surfaces[4], + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservations[4], + freeSurface: { _ in + let hasCurrentTask = withUnsafeCurrentTask { $0 != nil } + workerState.withLock { state in + state.active += 1 + state.maximumActive = max( + state.maximumActive, + state.active + ) + state.observedCurrentTask = + state.observedCurrentTask || hasCurrentTask + state.startsByIndex[4, default: 0] += 1 + } + workerStarted.continuation.yield(4) + releaseWorkers[4].wait() + workerState.withLock { $0.active -= 1 } + } + ) + ) + tickets.append(fifthTicket) + + releaseWorkers[0].signal() + let fifthStartedIndex = try #require( + await workerStartedIterator.next() + ) + #expect(fifthStartedIndex == 4) + for index in 1...makeStream() - let releaseStuckFree = DispatchSemaphore(value: 0) - let freedSurfaceBits = OSAllocatedUnfairLock(initialState: Set()) + let firstFreeStarted = AsyncStream.makeStream() + let releaseFirstFree = DispatchSemaphore(value: 0) defer { - releaseStuckFree.signal() - stuckFreeStarted.continuation.finish() + releaseFirstFree.signal() + firstFreeStarted.continuation.finish() } + let firstReservation = try #require( + coordinator.reserveRuntimeSurfaceOwnership() + ) - let stuckTicket = coordinator.enqueueRuntimeTeardown( - id: UUID(), - workspaceId: UUID(), - reason: "test.stuckClose", - surface: surfaces[0], - callbackContext: nil, - freeSurface: { _ in - stuckFreeStarted.continuation.yield() - _ = releaseStuckFree.wait(timeout: .distantFuture) - } + let firstTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.stuckClose", + surface: surfaces[0], + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: firstReservation, + freeSurface: { _ in + firstFreeStarted.continuation.yield() + releaseFirstFree.wait() + } + ) ) - var stuckFreeIterator = stuckFreeStarted.stream.makeAsyncIterator() - _ = await stuckFreeIterator.next() + var firstFreeIterator = firstFreeStarted.stream.makeAsyncIterator() + _ = await firstFreeIterator.next() - let laterTickets = surfaces.dropFirst().map { surface in + let secondReservation = try #require( + coordinator.reserveRuntimeSurfaceOwnership() + ) + let thirdReservation = try #require( + coordinator.reserveRuntimeSurfaceOwnership() + ) + + let secondTicket = try requireTeardownTicket( coordinator.enqueueRuntimeTeardown( id: UUID(), workspaceId: UUID(), - reason: "test.laterClose", - surface: surface, + reason: "test.closeAfterStuckClose", + surface: surfaces[1], callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: secondReservation, + freeSurface: { _ in } + ) + ) + let thirdTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.secondCloseAfterStuckClose", + surface: surfaces[2], + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: thirdReservation, + freeSurface: { _ in } + ) + ) + + let secondCompleted = await secondTicket.wait(timeout: .seconds(1)) + let thirdCompleted = await thirdTicket.wait(timeout: .seconds(1)) + releaseFirstFree.signal() + + #expect(secondCompleted, "one stuck native free stranded the next explicit close") + #expect(thirdCompleted, "one stuck native free stranded the remaining close worker") + #expect(await firstTicket.wait(timeout: .seconds(1))) + } + + @MainActor + @Test func ownershipReservationsKeepIngressCapacityForEveryNativeFree() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + maximumRuntimeSurfaceOwnerCount: 2 + ) + let recorder = FreedSurfaceRecorder() + let surfaces = (0..<2).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + defer { for surface in surfaces { surface.deallocate() } } + let isolatedReservation = try #require( + await coordinator.reserveIsolatedHibernationTeardown() + ) + let runtimeReservations = try (0...makeStream() + let cancelledFreeStarted = AsyncStream.makeStream() + let releaseActiveFrees = DispatchSemaphore(value: 0) + defer { + releaseActiveFrees.signal() + releaseActiveFrees.signal() + activeFreesStarted.continuation.finish() + cancelledFreeStarted.continuation.finish() + } + + let activeTickets = try (0..<2).map { index in + try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.activeBeforeCancellation", + surface: surfaces[index], + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: reservations[index], + freeSurface: { _ in + activeFreesStarted.continuation.yield() + releaseActiveFrees.wait() } + ) + ) + } + var activeFreesIterator = activeFreesStarted.stream.makeAsyncIterator() + _ = await activeFreesIterator.next() + _ = await activeFreesIterator.next() + + let cancelledTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.cancelledBeforeStart", + surface: surfaces[2], + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: reservations[2], + freeSurface: { _ in + cancelledFreeStarted.continuation.yield() } ) + ) + #expect( + await coordinator.cancelRuntimeTeardown( + ticketID: cancelledTicket.id + ) + ) + + releaseActiveFrees.signal() + var cancelledFreeIterator = + cancelledFreeStarted.stream.makeAsyncIterator() + _ = await cancelledFreeIterator.next() + #expect(await cancelledTicket.wait(timeout: nil)) + + releaseActiveFrees.signal() + for ticket in activeTickets { + #expect(await ticket.wait(timeout: nil)) } + } - for ticket in laterTickets { - try #require( - await ticket.wait(timeout: .seconds(1)), - "a stuck native free stranded a later close" + @Test func cancellationDuringRunUsesNormalCompletionSignal() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { surface.deallocate() } + let freeStarted = AsyncStream.makeStream() + let releaseFree = DispatchSemaphore(value: 0) + defer { + releaseFree.signal() + freeStarted.continuation.finish() + } + let runtimeReservation = try #require( + coordinator.reserveRuntimeSurfaceOwnership() + ) + + let ticket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.cancelledDuringRun", + surface: surface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservation, + freeSurface: { _ in + freeStarted.continuation.yield() + releaseFree.wait() + } + ) + ) + var freeStartedIterator = freeStarted.stream.makeAsyncIterator() + _ = await freeStartedIterator.next() + + #expect( + await coordinator.cancelRuntimeTeardown(ticketID: ticket.id) + ) + releaseFree.signal() + + #expect(await ticket.wait(timeout: nil)) + #expect(coordinator.debugRuntimeSurfaceOwnerCount == 0) + } + + @Test func completionReleasesResourcesAndSlotExactlyOnce() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let surfaces = (0..<2).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + defer { for surface in surfaces { surface.deallocate() } } + let recorder = TeardownLifetimeRecorder() + let lease = LifetimeRecordingByteTeeLease(recorder: recorder) + let runtimeReservations = try (0...makeStream() + let releaseFrees = DispatchSemaphore(value: 0) + defer { + releaseFrees.signal() + releaseFrees.signal() + freeStarted.continuation.finish() + } + let runtimeReservations = try (0..<2).map { _ in + try #require(coordinator.reserveRuntimeSurfaceOwnership()) + } + + let firstTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.firstStuckClose", + surface: surfaces[0], + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservations[0], + freeSurface: { _ in + freeStarted.continuation.yield() + releaseFrees.wait() + } + ) + ) + let secondTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.secondStuckClose", + surface: surfaces[1], + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservations[1], + freeSurface: { _ in + freeStarted.continuation.yield() + releaseFrees.wait() + } + ) + ) + var freeStartedIterator = freeStarted.stream.makeAsyncIterator() + _ = await freeStartedIterator.next() + _ = await freeStartedIterator.next() + + #expect(await coordinator.debugCloseTeardownDegraded) + #expect(await coordinator.debugPendingTeardownCount == 2) + #expect(coordinator.debugRuntimeSurfaceOwnerCount == 2) + + #expect(coordinator.reserveRuntimeSurfaceOwnership() == nil) + #expect(coordinator.debugRuntimeSurfaceOwnerCount == 2) + + let recovery = AsyncStream< + TerminalSurfaceRuntimeOwnershipReservation + >.makeStream(bufferingPolicy: .bufferingNewest(1)) + let recoveryID = UUID() + #expect( + coordinator.reserveRuntimeSurfaceOwnership( + recoveryID: recoveryID, + onRecovery: { reservation in + recovery.continuation.yield(reservation) + recovery.continuation.finish() + } + ) == .deferred + ) + + releaseFrees.signal() + var recoveryIterator = recovery.stream.makeAsyncIterator() + let recoveredReservation = try #require( + await recoveryIterator.next() + ) + coordinator.cancelRuntimeSurfaceOwnership(recoveredReservation) + releaseFrees.signal() + #expect(await firstTicket.wait(timeout: .seconds(1))) + #expect(await secondTicket.wait(timeout: .seconds(1))) + #expect(coordinator.debugRuntimeSurfaceOwnerCount == 0) + #expect(await !coordinator.debugCloseTeardownDegraded) + let recoveredTicketReservation = try #require( + coordinator.reserveRuntimeSurfaceOwnership() + ) + let recoveredTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.closeAfterWorkerRecovery", + surface: surfaces[2], + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: recoveredTicketReservation, + freeSurface: { _ in } + ) + ) + #expect(await recoveredTicket.wait(timeout: .seconds(1))) + } + + @MainActor + @Test func staleRecoveryHeadDoesNotStrandTheNextWaiter() async throws { + let admission = TerminalSurfaceRuntimeOwnershipAdmission( + maximumOwnerCount: 2 + ) + let firstOwner = try #require(admission.reserve()) + let secondOwner = try #require(admission.reserve()) + var staleProbe: NSObject? = NSObject() + weak let releasedProbe = staleProbe + let staleRecoveryID = UUID() + let nextRecoveryID = UUID() + var staleRecoveryDeclined = false + var nextRecoveryRan = false + let nextRecovery = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + + let staleResult = admission.reserve( + recoveryID: staleRecoveryID, + onRecovery: { [weak staleProbe] reservation in + guard staleProbe != nil else { + staleRecoveryDeclined = true + admission.release(reservation) + return + } + admission.release(reservation) + } + ) + let nextResult = admission.reserve( + recoveryID: nextRecoveryID, + onRecovery: { reservation in + nextRecoveryRan = true + nextRecovery.continuation.yield() + nextRecovery.continuation.finish() + admission.release(reservation) + } + ) + #expect(staleResult == .deferred) + #expect(nextResult == .deferred) + + admission.release(firstOwner) + staleProbe = nil + #expect(releasedProbe == nil) + var nextRecoveryIterator = nextRecovery.stream.makeAsyncIterator() + _ = await nextRecoveryIterator.next() + + #expect(staleRecoveryDeclined) + #expect( + nextRecoveryRan, + "a stale dequeued recovery callback stranded the next waiter despite free capacity" + ) + admission.cancelRecovery(nextRecoveryID) + admission.release(secondOwner) + } + + @MainActor + @Test func recoveryGrantsOnlyOneWaiterAtATime() async { + let admission = TerminalSurfaceRuntimeOwnershipAdmission( + maximumOwnerCount: 4 + ) + admission.setCloseTeardownDegraded(true) + var recoveredCount = 0 + let recoveries = AsyncStream.makeStream() + + for _ in 0..<3 { + let result = admission.reserve( + recoveryID: UUID(), + onRecovery: { reservation in + recoveredCount += 1 + recoveries.continuation.yield() + admission.release(reservation) + } + ) + #expect(result == .deferred) + } + + admission.setCloseTeardownDegraded(false) + #expect( + admission.debugOwnerCount == 1, + "admission reserved every available recovery slot before the main actor ran" + ) + var recoveryIterator = recoveries.stream.makeAsyncIterator() + for _ in 0..<3 { + _ = await recoveryIterator.next() + } + recoveries.continuation.finish() + #expect(recoveredCount == 3) + #expect(admission.debugOwnerCount == 0) + } + + @MainActor + @Test func recoveryAdmissionReservesImmediatelyWhenCapacityIsAvailable() { + let admission = TerminalSurfaceRuntimeOwnershipAdmission( + maximumOwnerCount: 2 + ) + var recoveryRan = false + + let result = admission.reserve( + recoveryID: UUID(), + onRecovery: { _ in recoveryRan = true } + ) + let reservation: TerminalSurfaceRuntimeOwnershipReservation + switch result { + case .reserved(let admittedReservation): + reservation = admittedReservation + case .deferred, .rejected: + Issue.record("available ownership did not reserve immediately") + return + } + + #expect(admission.contains(reservation)) + #expect(admission.debugOwnerCount == 1) + #expect(!recoveryRan) + admission.release(reservation) + #expect(admission.debugOwnerCount == 0) + } + + @MainActor + @Test func queuedRecoveryRetryReservesImmediatelyWhenCapacityIsAvailable() async throws { + let admission = TerminalSurfaceRuntimeOwnershipAdmission( + maximumOwnerCount: 2 + ) + let firstOwner = try #require(admission.reserve()) + let secondOwner = try #require(admission.reserve()) + let leadingRecovery = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + let recoveryID = UUID() + var queuedRecoveryRan = false + var retryRecoveryRan = false + + #expect( + admission.reserve( + recoveryID: UUID(), + onRecovery: { reservation in + admission.release(reservation) + leadingRecovery.continuation.yield() + leadingRecovery.continuation.finish() + } + ) == .deferred + ) + #expect( + admission.reserve( + recoveryID: recoveryID, + onRecovery: { reservation in + queuedRecoveryRan = true + admission.release(reservation) + } + ) == .deferred + ) + + admission.release(firstOwner) + admission.release(secondOwner) + let retryResult = admission.reserve( + recoveryID: recoveryID, + onRecovery: { _ in retryRecoveryRan = true } + ) + let retryReservation: TerminalSurfaceRuntimeOwnershipReservation + switch retryResult { + case .reserved(let reservation): + retryReservation = reservation + case .deferred, .rejected: + Issue.record("available same-ID retry did not reserve immediately") + return } - #expect(await stuckTicket.wait(timeout: .zero) == false) + + #expect(admission.contains(retryReservation)) + #expect(!queuedRecoveryRan) + #expect(!retryRecoveryRan) + admission.release(retryReservation) + var leadingRecoveryIterator = leadingRecovery.stream.makeAsyncIterator() + _ = await leadingRecoveryIterator.next() + #expect(admission.debugOwnerCount == 0) + } + + @MainActor + @Test func recoveryQueueRejectsNewIDsAtOwnershipCapacity() async throws { + let admission = TerminalSurfaceRuntimeOwnershipAdmission( + maximumOwnerCount: 2 + ) + let firstOwner = try #require(admission.reserve()) + let secondOwner = try #require(admission.reserve()) + let recoveries = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(2) + ) + let firstRecoveryID = UUID() + let secondRecoveryID = UUID() + var replacedRecoveryRan = false + var thirdRecoveryRan = false + + let firstResult = admission.reserve( + recoveryID: firstRecoveryID, + onRecovery: { _ in replacedRecoveryRan = true } + ) + #expect(firstResult == .deferred) + let replacementResult = admission.reserve( + recoveryID: firstRecoveryID, + onRecovery: { reservation in + admission.release(reservation) + recoveries.continuation.yield(0) + } + ) + #expect(replacementResult == .deferred) + let secondResult = admission.reserve( + recoveryID: secondRecoveryID, + onRecovery: { reservation in + admission.release(reservation) + recoveries.continuation.yield(1) + recoveries.continuation.finish() + } + ) + #expect(secondResult == .deferred) + let thirdResult = admission.reserve( + recoveryID: UUID(), + onRecovery: { reservation in + thirdRecoveryRan = true + admission.release(reservation) + } + ) + #expect(thirdResult == .rejected) + + admission.release(firstOwner) + var recoveryIterator = recoveries.stream.makeAsyncIterator() + #expect(await recoveryIterator.next() == 0) + #expect(await recoveryIterator.next() == 1) + #expect( + admission.debugOwnerCount == 1, + "a third recovery reservation exceeded the bounded owner count" + ) + #expect( + !replacedRecoveryRan, + "same-ID recovery replacement retained the prior action" + ) #expect( - freedSurfaceBits.withLock { $0 } == - Set(surfaces.dropFirst().map { UInt(bitPattern: $0) }) + !thirdRecoveryRan, + "a recovery request beyond the owner capacity was retained" ) + admission.release(secondOwner) + } + + @MainActor + @Test func updatedRecoveryWaitsForItsPendingFailureDelivery() throws { + let admission = TerminalSurfaceRuntimeOwnershipAdmission( + maximumOwnerCount: 2 + ) + let owners = try (0..<2).map { _ in + try #require(admission.reserve()) + } + let recoveryID = UUID() + defer { + admission.cancelRecovery(recoveryID) + for owner in owners { + admission.release(owner) + } + } + var firstFailureCount = 0 + var updatedFailureCount = 0 - releaseStuckFree.signal() - #expect(await stuckTicket.wait(timeout: .seconds(1))) + #expect( + admission.reserve( + recoveryID: recoveryID, + onRecovery: { _ in }, + onFailure: { firstFailureCount += 1 } + ) == .deferred + ) + let firstFailures = + admission.failRecoveriesForAllStalledCloseTeardowns() + #expect(firstFailures.count == 1) + admission.clearAllStalledCloseTeardowns() + + #expect( + admission.reserve( + recoveryID: recoveryID, + onRecovery: { _ in }, + onFailure: { updatedFailureCount += 1 } + ) == .deferred + ) + let duplicateFailures = + admission.failRecoveriesForAllStalledCloseTeardowns() + #expect( + duplicateFailures.isEmpty, + "an updated recovery published twice before its first failure delivery completed" + ) + + for failure in firstFailures { + failure() + } + admission.completeStalledCloseRecoveryFailures(firstFailures) + for failure in duplicateFailures { + failure() + } + if !duplicateFailures.isEmpty { + admission.completeStalledCloseRecoveryFailures( + duplicateFailures + ) + } + #expect(firstFailureCount == 1) + #expect(updatedFailureCount == duplicateFailures.count) } @Test func stuckHibernationFreeDoesNotStrandAnotherAdmissionOrClose() async throws { @@ -201,6 +1221,9 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec queuedIsolatedSurface.deallocate() closeSurface.deallocate() } + let runtimeReservations = try (0..<3).map { _ in + try #require(coordinator.reserveRuntimeSurfaceOwnership()) + } let isolatedFreeStarted = AsyncStream.makeStream() let releaseIsolatedFree = DispatchSemaphore(value: 0) let secondIsolatedFreeCount = OSAllocatedUnfairLock(initialState: 0) @@ -213,20 +1236,23 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec let isolatedReservation = try #require( await coordinator.reserveIsolatedHibernationTeardown() ) - let isolatedTicket = coordinator.enqueueRuntimeTeardown( - id: UUID(), - workspaceId: UUID(), - reason: "test.isolatedHibernation", - surface: isolatedSurface, - callbackContext: nil, - manualIOContext: nil, - byteTeeLease: nil, - executionLane: .isolatedHibernation, - isolatedHibernationReservation: isolatedReservation, - freeSurface: { _ in - isolatedFreeStarted.continuation.yield() - _ = releaseIsolatedFree.wait(timeout: .distantFuture) - } + let isolatedTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.isolatedHibernation", + surface: isolatedSurface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservations[0], + executionLane: .isolatedHibernation, + isolatedHibernationReservation: isolatedReservation, + freeSurface: { _ in + isolatedFreeStarted.continuation.yield() + _ = releaseIsolatedFree.wait(timeout: .distantFuture) + } + ) ) var isolatedFreeIterator = isolatedFreeStarted.stream.makeAsyncIterator() _ = await isolatedFreeIterator.next() @@ -235,29 +1261,37 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec await coordinator.reserveIsolatedHibernationTeardown() ) #expect(await coordinator.reserveIsolatedHibernationTeardown() == nil) - let secondIsolatedTicket = coordinator.enqueueRuntimeTeardown( - id: UUID(), - workspaceId: UUID(), - reason: "test.secondIsolatedHibernation", - surface: queuedIsolatedSurface, - callbackContext: nil, - manualIOContext: nil, - byteTeeLease: nil, - executionLane: .isolatedHibernation, - isolatedHibernationReservation: secondReservation, - freeSurface: { _ in - secondIsolatedFreeCount.withLock { $0 += 1 } - } + let secondIsolatedTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.secondIsolatedHibernation", + surface: queuedIsolatedSurface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservations[1], + executionLane: .isolatedHibernation, + isolatedHibernationReservation: secondReservation, + freeSurface: { _ in + secondIsolatedFreeCount.withLock { $0 += 1 } + } + ) ) - let closeTicket = coordinator.enqueueRuntimeTeardown( - id: UUID(), - workspaceId: UUID(), - reason: "test.close", - surface: closeSurface, - callbackContext: nil, - freeSurface: { _ in - closeFreeCount.withLock { $0 += 1 } - } + let closeTicket = try requireTeardownTicket( + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.boundedClose", + surface: closeSurface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, + runtimeOwnershipReservation: runtimeReservations[2], + freeSurface: { _ in + closeFreeCount.withLock { $0 += 1 } + } + ) ) #expect(await closeTicket.wait(timeout: .seconds(1))) @@ -292,6 +1326,9 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec releaseIsolatedFree.signal() isolatedFreeStarted.continuation.finish() } + let runtimeReservations = try (0...makeStream() + let allowFree = DispatchSemaphore(value: 0) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + freeStarted.continuation.yield() + allowFree.wait() + } + defer { + allowFree.signal() + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil + } + + #expect(surface.suspendRuntimeSurfaceForAgentHibernation(reason: "test.hibernate")) + var freeStartedIterator = freeStarted.stream.makeAsyncIterator() + _ = await freeStartedIterator.next() + + let closeTicket = try #require(surface.teardownSurface()) + #expect(await closeTicket.wait(timeout: .zero) == false) + #expect(await surface.waitForCloseRuntimeTeardown(timeout: .zero) == false) + + allowFree.signal() + #expect(await surface.waitForCloseRuntimeTeardown(timeout: .seconds(1))) + freeStarted.continuation.finish() + } + @Test func hibernationAdmissionFailurePreservesLiveRuntimeSurface() throws { let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let firstReservation = try #require( @@ -278,11 +328,14 @@ import Testing #expect(weakBox == nil, "manual IO write box must still be released after the native free") } - @Test func coordinatorReleasesTransportedTeeLeaseOnlyAfterFreeCompletes() async { + @Test func coordinatorReleasesTransportedTeeLeaseOnlyAfterFreeCompletes() async throws { let recorder = TeardownOrderRecorder() let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) defer { surface.deallocate() } + let runtimeReservation = try #require( + coordinator.reserveRuntimeSurfaceOwnership() + ) coordinator.enqueueRuntimeTeardown( id: UUID(), @@ -292,6 +345,7 @@ import Testing callbackContext: nil, manualIOContext: nil, byteTeeLease: RecordingTerminalByteTeeLease(recorder: recorder), + runtimeOwnershipReservation: runtimeReservation, freeSurface: { _ in recorder.record(.nativeFree) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7d71a134ac02..1e9c819f9fda 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -241795,6 +241795,23 @@ } } }, + "terminal.surface.runtimeCreation.capacityExceeded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to start this terminal because too many terminal sessions are still closing." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了処理中のターミナルセッションが多すぎるため、このターミナルを開始できません。" + } + } + } + }, "terminal.shortcut.name.ctrlL": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5074ea83065d..d32166bba936 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -294,7 +294,17 @@ extension GhosttySurfaceCallbackContext { // The surface model drives its views through the CmuxTerminal hosting seams; // the concrete view classes conform here. extension GhosttyNSView: TerminalSurfaceNativeViewing {} -extension GhosttySurfaceScrollView: TerminalSurfacePaneHosting {} +extension GhosttySurfaceScrollView: TerminalSurfacePaneHosting { + func showRuntimeSurfaceCreationFailure(message: String) { + runtimeSurfaceCreationFailureLabel.stringValue = message + runtimeSurfaceCreationFailureLabel.isHidden = false + } + + func clearRuntimeSurfaceCreationFailure() { + runtimeSurfaceCreationFailureLabel.stringValue = "" + runtimeSurfaceCreationFailureLabel.isHidden = true + } +} extension TerminalSurface { /// Concrete-typed convenience over ``TerminalSurface/paneHost`` for app @@ -8203,6 +8213,7 @@ final class GhosttySurfaceScrollView: NSView { private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer private var cloudTerminalReconnectOverlayView: CloudTerminalReconnectOverlayView? + private let runtimeSurfaceCreationFailureLabel = NSTextField(wrappingLabelWithString: "") private var hasVisibilityRevealRefreshScheduled = false var isRightSidebarDockSurface: Bool { surfaceView.terminalSurface?.focusPlacement == .rightSidebarDock @@ -8506,6 +8517,28 @@ final class GhosttySurfaceScrollView: NSView { addSubview(mobileViewportBorderOverlayView, positioned: .above, relativeTo: scrollView) paneDropTargetView.hostedView = self addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) + runtimeSurfaceCreationFailureLabel.translatesAutoresizingMaskIntoConstraints = false + runtimeSurfaceCreationFailureLabel.alignment = .center + runtimeSurfaceCreationFailureLabel.textColor = .labelColor + runtimeSurfaceCreationFailureLabel.drawsBackground = true + runtimeSurfaceCreationFailureLabel.backgroundColor = .windowBackgroundColor.withAlphaComponent(0.94) + runtimeSurfaceCreationFailureLabel.wantsLayer = true + runtimeSurfaceCreationFailureLabel.layer?.cornerRadius = 8 + runtimeSurfaceCreationFailureLabel.layer?.masksToBounds = true + runtimeSurfaceCreationFailureLabel.isHidden = true + addSubview(runtimeSurfaceCreationFailureLabel, positioned: .above, relativeTo: nil) + NSLayoutConstraint.activate([ + runtimeSurfaceCreationFailureLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + runtimeSurfaceCreationFailureLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + runtimeSurfaceCreationFailureLabel.leadingAnchor.constraint( + greaterThanOrEqualTo: leadingAnchor, + constant: 24 + ), + runtimeSurfaceCreationFailureLabel.trailingAnchor.constraint( + lessThanOrEqualTo: trailingAnchor, + constant: -24 + ), + ]) synchronizeScrollbarAppearance() inactiveOverlayView.wantsLayer = true inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor diff --git a/cmux-tui/Cargo.lock b/cmux-tui/Cargo.lock index ec332967b280..f588070c2dc5 100644 --- a/cmux-tui/Cargo.lock +++ b/cmux-tui/Cargo.lock @@ -129,7 +129,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -158,9 +158,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -463,9 +463,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -584,6 +584,7 @@ name = "cmux-pty" version = "0.1.0" dependencies = [ "anyhow", + "cmux-tui-process", "libc", "portable-pty", ] @@ -621,6 +622,7 @@ dependencies = [ "cmux-relay", "cmux-remote-protocol", "cmux-tui-core", + "cmux-tui-process", "cmux-tui-source-watch", "diffy", "fs4", @@ -648,6 +650,7 @@ dependencies = [ "tower-http", "url", "uuid", + "wait-timeout", "x25519-dalek", "zeroize", ] @@ -714,9 +717,11 @@ dependencies = [ "cmux-pty", "cmux-remote", "cmux-remote-protocol", + "cmux-tui-cdp", "cmux-tui-core", "cmux-tui-machine-agent-protocol", "cmux-tui-machine-protocol", + "cmux-tui-process", "crossterm", "getrandom 0.3.4", "ghostty-vt", @@ -749,6 +754,8 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", + "cmux-tui-process", + "libc", "serde_json", "tungstenite", ] @@ -762,6 +769,7 @@ dependencies = [ "cmux-pty", "cmux-remote-protocol", "cmux-tui-cdp", + "cmux-tui-process", "flate2", "fs4", "getrandom 0.3.4", @@ -803,6 +811,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cmux-tui-process" +version = "0.1.0" +dependencies = [ + "libc", + "socket2 0.5.10", + "tokio", +] + [[package]] name = "cmux-tui-source-watch" version = "0.0.0" @@ -813,7 +830,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1439,9 +1456,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "finl_unicode" @@ -1813,7 +1830,7 @@ dependencies = [ "jni 0.22.4", "rand 0.10.2", "rustls", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tokio", "tokio-rustls", @@ -1835,7 +1852,7 @@ dependencies = [ "prefix-trie", "rand 0.10.2", "ring", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -1863,7 +1880,7 @@ dependencies = [ "rustls", "smallvec", "system-configuration", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tracing", @@ -2432,7 +2449,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2480,9 +2497,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -2523,7 +2540,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2729,9 +2746,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -2863,7 +2880,7 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2966,7 +2983,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -2994,7 +3011,7 @@ dependencies = [ "rustls-pki-types", "slab", "sorted-index-buffer", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -3584,9 +3601,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" dependencies = [ "serde", ] @@ -3860,7 +3877,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -4079,7 +4096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4890,11 +4907,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -4910,9 +4927,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -5290,7 +5307,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5494,9 +5511,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -5507,9 +5524,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -5517,9 +5534,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5527,9 +5544,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -5540,9 +5557,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -5562,9 +5579,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -6019,7 +6036,7 @@ dependencies = [ "futures", "log", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows", "windows-core", ] @@ -6043,7 +6060,7 @@ dependencies = [ "pharos", "rustc_version", "send_wrapper", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -6075,15 +6092,15 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmltree" diff --git a/cmux-tui/Cargo.toml b/cmux-tui/Cargo.toml index 6d819ad37aba..4433d8dcf737 100644 --- a/cmux-tui/Cargo.toml +++ b/cmux-tui/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/ghostty-vt", "crates/cmux-tui-cdp", "crates/cmux-tui-core", + "crates/cmux-tui-process", "crates/cmux-pty", "crates/cmux-remote-protocol", "crates/cmux-remote", @@ -69,6 +70,7 @@ ghostty-vt-sys = { path = "crates/ghostty-vt-sys" } ghostty-vt = { path = "crates/ghostty-vt" } cmux-tui-core = { path = "crates/cmux-tui-core" } cmux-tui-cdp = { path = "crates/cmux-tui-cdp" } +cmux-tui-process = { path = "crates/cmux-tui-process" } cmux-pty = { path = "crates/cmux-pty" } cmux-remote-protocol = { path = "crates/cmux-remote-protocol" } cmux-remote = { path = "crates/cmux-remote" } @@ -90,7 +92,7 @@ png = "0.17" libc = "0.2" tungstenite = { version = "0.29", default-features = false, features = ["handshake"] } uds_windows = "1.2" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock", "Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] } regex = "1" jsonschema = { version = "0.30", default-features = false } flate2 = "1" diff --git a/cmux-tui/README.md b/cmux-tui/README.md index 95fe38a3f88d..d3e4e06236a4 100644 --- a/cmux-tui/README.md +++ b/cmux-tui/README.md @@ -47,6 +47,13 @@ cargo run -p cmux-tui -- machine-agent --session agents The default session is `main`. Default sockets live at `$TMPDIR/cmux-tui-/.sock`; use `--socket ` for an explicit path. Detach from an attached TUI with prefix `d`, which is `Ctrl-b d` by default. +Linux requires kernel 5.1 or newer, a mounted `/proc`, and permission to call +`pidfd_send_signal`. Linux 5.3 and newer use `pidfd_open`; Linux 5.1 and 5.2 +use an exact `/proc/` process handle. + +Published `cmux-tui` packages require macOS 15 or newer. This TUI package +requirement is separate from the cmux macOS app's system requirement. + `attach --terminal ` attaches one PTY terminal by its stable ID from `cmux terminal list`. It uses the full host terminal without the sidebar, status bar, pane border, or other tabs. Pane layout stays tiled by default. Press `Ctrl-b g` to append a terminal to the right at two-thirds of the viewport width. The existing layout keeps its width, so a continuous horizontal scrollbar appears in the status bar. Focusing a pane reveals it with an animated viewport movement. `Alt-n` reapplies Zellij's automatic layout inside the focused horizontal column. `Ctrl-b U` undoes the latest structural layout action on the focused screen; undoing pane creation asks for confirmation before closing the pane. diff --git a/cmux-tui/bindings/ERGONOMICS.md b/cmux-tui/bindings/ERGONOMICS.md index 2a9c8438b6cc..ed708121c66f 100644 --- a/cmux-tui/bindings/ERGONOMICS.md +++ b/cmux-tui/bindings/ERGONOMICS.md @@ -2,7 +2,7 @@ The seven public SDKs expose handwritten resource handles over the reviewed 124-operation `cmux.protocol/2` catalog. The raw protocol inventory is a -separate compatibility surface with 101 commands and 46 events. Deterministic +separate compatibility surface with 102 commands and 46 events. Deterministic generation is limited to those private protocol-12 models under each package's explicit `raw` namespace. Consumers do not run a generator or install a generator runtime. @@ -74,7 +74,7 @@ and secret redaction. The exact-binary live matrix adds one isolated create, run, exit, restart, and cleanup flow per language. TypeScript repeats it over authenticated WebSocket, for eight live transport runs. The separate raw protocol-12 suite runs 266 -compatibility checks over its 101 commands and 46 events. +compatibility checks over its 102 commands and 46 events. Each package suite also opens a stream with a short request deadline, leaves it idle past that deadline, then delivers and cancels normally. This separates diff --git a/cmux-tui/bindings/RELEASING.md b/cmux-tui/bindings/RELEASING.md index 7d9acc1d4775..1941329fc660 100644 --- a/cmux-tui/bindings/RELEASING.md +++ b/cmux-tui/bindings/RELEASING.md @@ -29,6 +29,18 @@ python -m pip install cmux-sdk Do not publish SDK contents through `tui-publish-npm.yml` or `tui-publish-pypi.yml`. +## Support matrix + +| SDK | Minimum toolchain/runtime | Runtime dependencies | Distribution | +| --- | --- | --- | --- | +| TypeScript | Node.js 20; browser ESM for the browser entry | none | npm package | +| Python | CPython 3.9 | none | PyPI wheel and source distribution | +| Rust | Rust 1.88 | base: `base64`, `getrandom`, `libc`, `serde`, `serde_json`; sidebar: `crossterm`, `ratatui`, `serde_json` | crates.io crates `cmux-client` and `cmux-sidebar` | +| Go | Go 1.22 | standard library only | Go module tag | +| Java | Java 17 | standard library only | Maven artifact | +| C++ | C++20 and CMake 3.20 | standard library and platform socket APIs | installable CMake package | +| Zig | Zig 0.15.2 | standard library only | source package | + All packages target mux protocol 12 and expose the same generated command and event catalogs. The release preflight rejects runtime inventory drift and stale generated layers for all four publish targets before it runs their shared diff --git a/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json b/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json index 0fe0bec2c1e2..ff6e95981718 100644 --- a/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json @@ -2,8 +2,8 @@ "files": [ { "path": "include/cmux/raw/generated/commands.hpp", - "sha256": "bfd24d45435125c189f72a041754afedd2692e6df48e9576e08428cac69604ac", - "size": 14743 + "sha256": "d980dc016ea66cf80a40b428279da9c728d1c10c9acf88c900a306439218a556", + "size": 14857 }, { "path": "include/cmux/raw/generated/events.hpp", @@ -12,17 +12,17 @@ }, { "path": "include/cmux/raw/generated/models.hpp", - "sha256": "4a7942239f0f24689ca7c108555a5285d5a0776e0a3f9a80839d4b67482c4848", - "size": 122760 + "sha256": "36921119ae61f0ee338e203d906c2fc0aa952ff1c4bd2c155e79d398ffb88e2d", + "size": 123556 }, { "path": "src/raw/generated/protocol.cpp", - "sha256": "f9fd398d4a479f98661a9902c481a46e3489f8f8b1d10c8d6eb1b47a98d25ad5", - "size": 804588 + "sha256": "23ab08ea51f6954311c1230449a8c669539e5fa95599162048e7274d48f27f0c", + "size": 808252 } ], "format": 1, - "ir_sha256": "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529", + "ir_sha256": "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740", "language": "cpp", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/cpp/include/cmux/raw/generated/commands.hpp b/cmux-tui/bindings/cpp/include/cmux/raw/generated/commands.hpp index 112400ae9082..a7be1a4c1c9a 100644 --- a/cmux-tui/bindings/cpp/include/cmux/raw/generated/commands.hpp +++ b/cmux-tui/bindings/cpp/include/cmux/raw/generated/commands.hpp @@ -131,6 +131,7 @@ class Client { [[nodiscard]] Result set_split_ratio(const SetSplitRatioRequest& request, RequestOptions options = {}); [[nodiscard]] Result set_viewport_pane_width(const SetViewportPaneWidthRequest& request, RequestOptions options = {}); [[nodiscard]] Result set_window_title(const SetWindowTitleRequest& request, RequestOptions options = {}); + [[nodiscard]] Result shutdown(const ShutdownRequest& request = {}, RequestOptions options = {}); [[nodiscard]] Result shutdown_daemon(const ShutdownDaemonRequest& request, RequestOptions options = {}); [[nodiscard]] Result sidebar_plugin(const SidebarPluginRequest& request, RequestOptions options = {}); [[nodiscard]] Result split(const SplitRequest& request, RequestOptions options = {}); diff --git a/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp b/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp index ff38ce1ce1e2..a7c7a8e83c8c 100644 --- a/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp +++ b/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp @@ -14,7 +14,7 @@ namespace cmux::raw { inline constexpr std::uint32_t kMuxProtocolVersion = 12U; -inline constexpr std::string_view kProtocolIrSha256 = "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529"; +inline constexpr std::string_view kProtocolIrSha256 = "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740"; struct AgentRecord; enum class AgentReportSource; @@ -93,6 +93,7 @@ struct ResourceSelectors; struct RunResult; struct Screen; struct SetCellPixelsResult; +struct ShutdownCleanupStatus; struct ShutdownDaemonResult; struct SidebarPluginResult; struct Size; @@ -211,6 +212,7 @@ struct SetRatioRequest; struct SetSplitRatioRequest; struct SetViewportPaneWidthRequest; struct SetWindowTitleRequest; +struct ShutdownRequest; struct ShutdownDaemonRequest; struct SidebarPluginRequest; struct SplitRequest; @@ -1241,6 +1243,13 @@ struct IdentifyRequest { friend bool operator==(const IdentifyRequest&, const IdentifyRequest&) = default; }; +struct ShutdownCleanupStatus { + bool degraded{}; + std::uint64_t pending{}; + bool retrying{}; + friend bool operator==(const ShutdownCleanupStatus&, const ShutdownCleanupStatus&) = default; +}; + struct IdentifyResult { Field build_commit{}; std::optional> capabilities{}; @@ -1251,6 +1260,7 @@ struct IdentifyResult { std::uint32_t protocol{}; std::string registry_id{}; std::string session{}; + std::optional shutdown_cleanup{}; std::uint64_t terminal_revision{}; std::string version{}; std::uint64_t workspace_revision{}; @@ -2203,6 +2213,10 @@ struct ShutdownDaemonResult { friend bool operator==(const ShutdownDaemonResult&, const ShutdownDaemonResult&) = default; }; +struct ShutdownRequest { + friend bool operator==(const ShutdownRequest&, const ShutdownRequest&) = default; +}; + struct SidebarPluginRequest { std::uint16_t cols{}; std::optional relaunch{}; @@ -2985,6 +2999,12 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const ShutdownCleanupStatus& value); + static Result decode(const Json& value); +}; + template <> struct Codec { static Result encode(const ShutdownDaemonResult& value); @@ -3693,6 +3713,12 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const ShutdownRequest& value); + static Result decode(const Json& value); +}; + template <> struct Codec { static Result encode(const ShutdownDaemonRequest& value); diff --git a/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp b/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp index bdff27df6815..10a9f1c3fe3c 100644 --- a/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp +++ b/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp @@ -1553,6 +1553,11 @@ Result Codec::encode(const IdentifyResult& value) { auto encoded_session = encode_value(value.session); if (!encoded_session) return std::move(encoded_session).error(); object.emplace("session", std::move(encoded_session).value()); + if (value.shutdown_cleanup) { + auto encoded = encode_value(*value.shutdown_cleanup); + if (!encoded) return std::move(encoded).error(); + object.emplace("shutdown_cleanup", std::move(encoded).value()); + } auto encoded_terminal_revision = encode_value(value.terminal_revision); if (!encoded_terminal_revision) return std::move(encoded_terminal_revision).error(); object.emplace("terminal_revision", std::move(encoded_terminal_revision).value()); @@ -1664,6 +1669,12 @@ Result Codec::decode(const Json& value) { if (!decoded) return std::move(decoded).error(); result.session = std::move(decoded).value(); } + const Json* field_shutdown_cleanup = value.find("shutdown_cleanup"); + if (field_shutdown_cleanup) { + auto decoded = decode_value(*field_shutdown_cleanup); + if (!decoded) return std::move(decoded).error(); + result.shutdown_cleanup = std::move(decoded).value(); + } const Json* field_terminal_revision = value.find("terminal_revision"); if (!field_terminal_revision) { return make_error(ErrorCode::decode, "missing required field 'terminal_revision'"); @@ -4574,6 +4585,55 @@ Result Codec::decode(const Json& value return result; } +Result Codec::encode(const ShutdownCleanupStatus& value) { + (void)value; + Json::Object object; + auto encoded_degraded = encode_value(value.degraded); + if (!encoded_degraded) return std::move(encoded_degraded).error(); + object.emplace("degraded", std::move(encoded_degraded).value()); + auto encoded_pending = encode_value(value.pending); + if (!encoded_pending) return std::move(encoded_pending).error(); + object.emplace("pending", std::move(encoded_pending).value()); + auto encoded_retrying = encode_value(value.retrying); + if (!encoded_retrying) return std::move(encoded_retrying).error(); + object.emplace("retrying", std::move(encoded_retrying).value()); + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + ShutdownCleanupStatus result{}; + const Json* field_degraded = value.find("degraded"); + if (!field_degraded) { + return make_error(ErrorCode::decode, "missing required field 'degraded'"); + } + if (field_degraded) { + auto decoded = decode_value(*field_degraded); + if (!decoded) return std::move(decoded).error(); + result.degraded = std::move(decoded).value(); + } + const Json* field_pending = value.find("pending"); + if (!field_pending) { + return make_error(ErrorCode::decode, "missing required field 'pending'"); + } + if (field_pending) { + auto decoded = decode_value(*field_pending); + if (!decoded) return std::move(decoded).error(); + result.pending = std::move(decoded).value(); + } + const Json* field_retrying = value.find("retrying"); + if (!field_retrying) { + return make_error(ErrorCode::decode, "missing required field 'retrying'"); + } + if (field_retrying) { + auto decoded = decode_value(*field_retrying); + if (!decoded) return std::move(decoded).error(); + result.retrying = std::move(decoded).value(); + } + return result; +} + Result Codec::encode(const ShutdownDaemonResult& value) { (void)value; Json::Object object; @@ -11749,6 +11809,19 @@ Result Codec::decode(const Json& v return result; } +Result Codec::encode(const ShutdownRequest& value) { + (void)value; + Json::Object object; + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + ShutdownRequest result{}; + return result; +} + Result Codec::encode(const ShutdownDaemonRequest& value) { (void)value; Json::Object object; @@ -17083,14 +17156,14 @@ constexpr std::array kCommand87FieldRequirements{{ constexpr std::array kCommand88FieldRequirements{{ {"transaction", 9U, "layout-undo-v1"}, }}; -constexpr std::array kCommand90FieldRequirements{{ +constexpr std::array kCommand91FieldRequirements{{ {"force", 10U, "daemon-handoff-force-v1"}, }}; -constexpr std::array kCommand93FieldRequirements{{ +constexpr std::array kCommand94FieldRequirements{{ {"surface", 9U, "surface-subscribe-filter"}, {"tree_events", 7U, ""}, }}; -constexpr std::array kCommands{{ +constexpr std::array kCommands{{ {"apply-layout", "control", 6U, "", false, "", "", std::span{}}, {"attach-surface", "frontend", 5U, "", true, "attach", "detached", std::span(kCommand1FieldRequirements)}, {"browser-activate", "frontend", 6U, "", false, "", "", std::span{}}, @@ -17181,10 +17254,11 @@ constexpr std::array kCommands{{ {"set-split-ratio", "control", 8U, "", false, "", "", std::span(kCommand87FieldRequirements)}, {"set-viewport-pane-width", "control", 9U, "viewport-column-resize-v1", false, "", "", std::span(kCommand88FieldRequirements)}, {"set-window-title", "control", 6U, "", false, "", "", std::span{}}, - {"shutdown-daemon", "local-admin", 9U, "", false, "", "", std::span(kCommand90FieldRequirements)}, + {"shutdown", "local-admin", 9U, "server-shutdown-v1", false, "", "", std::span{}}, + {"shutdown-daemon", "local-admin", 9U, "", false, "", "", std::span(kCommand91FieldRequirements)}, {"sidebar-plugin", "frontend", 6U, "", false, "", "", std::span{}}, {"split", "control", 5U, "", false, "", "", std::span{}}, - {"subscribe", "frontend", 5U, "", true, "subscribe", "", std::span(kCommand93FieldRequirements)}, + {"subscribe", "frontend", 5U, "", true, "subscribe", "", std::span(kCommand94FieldRequirements)}, {"swap-pane", "control", 6U, "", false, "", "", std::span{}}, {"terminal-events", "control", 9U, "", false, "", "", std::span{}}, {"undo-layout", "control", 9U, "layout-undo-v1", false, "", "", std::span{}}, @@ -18250,6 +18324,17 @@ Result Client::set_window_title( return decode_value(response.value()); } +Result Client::shutdown( + const ShutdownRequest& request, RequestOptions options) { + auto encoded = encode_value(request); + if (!encoded) return std::move(encoded).error(); + auto parameters = encoded.value().as_object(); + if (!parameters) return std::move(parameters).error(); + auto response = core_.request("shutdown", *parameters.value(), options.timeout); + if (!response) return std::move(response).error(); + return decode_value(response.value()); +} + Result Client::shutdown_daemon( const ShutdownDaemonRequest& request, RequestOptions options) { auto encoded = encode_value(request); diff --git a/cmux-tui/bindings/cpp/tests/test_generated.cpp b/cmux-tui/bindings/cpp/tests/test_generated.cpp index e41aaccac460..61600e4fe689 100644 --- a/cmux-tui/bindings/cpp/tests/test_generated.cpp +++ b/cmux-tui/bindings/cpp/tests/test_generated.cpp @@ -84,7 +84,7 @@ static_assert(std::is_same_v< static_assert(!std::is_copy_constructible_v); static_assert(std::is_move_constructible_v); -constexpr std::size_t kExpectedRawCommandCount = 101U; +constexpr std::size_t kExpectedRawCommandCount = 102U; constexpr std::array kViewportHistoryCommandNames{ "clear-history", "new-pane-right", diff --git a/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json b/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json index 1b2ae1332729..58c0352b048d 100644 --- a/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "generated_commands.go", - "sha256": "5380f31f8875c9971ac9cce71485c25e644b788b3a26abfd72dcc5b3a94ec782", - "size": 352684 + "sha256": "3a58c396e5c60b4947b551cd925680f6c2b5b34681444f77cd889eb29f1d3a1e", + "size": 353413 }, { "path": "generated_events.go", - "sha256": "ec64b7eab8bac42475eacf1779ff1ac5d71d06fbc27ad70eab3627619301b9ed", + "sha256": "5f3c4b5f6400a0b85603f4ba3ddce1c443a1e83d601b57e9d22d09eb9009e512", "size": 108363 }, { "path": "generated_metadata.go", - "sha256": "4b2d89239d7e09bc7f8775bc3309f65dfcbb1ccd2e6c1666cbd36e17e421b088", - "size": 47353 + "sha256": "e149a55d9b60e5178eb99900967bbfcbacfd2bcee9f02755cdfcf5b3c3b8da96", + "size": 47639 }, { "path": "generated_presence_test.go", - "sha256": "421e1f8b41c8b1175758ae25dcbfd2a7ec483d1e62ec6f8251e97ae58ff13fc7", - "size": 998718 + "sha256": "18da2784a89c1dc0c2e6d6bafbc0cb8d031f7962e8c2c81a4705bc4380b10486", + "size": 1001966 }, { "path": "generated_types.go", - "sha256": "c23522f1e2afc009ed09fdb3ef097216f0ac6e99257faa42368f5b87aed4a8bc", - "size": 270653 + "sha256": "e208dcbe5abe3dd47f74fcd231d5631c013f237c524fb0a880225434a72fc544", + "size": 272391 } ], "format": 1, - "ir_sha256": "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529", + "ir_sha256": "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740", "language": "go", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/go/raw/README.md b/cmux-tui/bindings/go/raw/README.md index 4b260084ad74..a711f8ee69a8 100644 --- a/cmux-tui/bindings/go/raw/README.md +++ b/cmux-tui/bindings/go/raw/README.md @@ -1,6 +1,6 @@ # cmux Go SDK -The Go SDK covers all 101 protocol-12 commands and 46 event shapes using only +The Go SDK covers all 102 protocol-12 commands and 46 event shapes using only the Go standard library. ```go diff --git a/cmux-tui/bindings/go/raw/client_test.go b/cmux-tui/bindings/go/raw/client_test.go index e7220542f884..a151991639ba 100644 --- a/cmux-tui/bindings/go/raw/client_test.go +++ b/cmux-tui/bindings/go/raw/client_test.go @@ -20,8 +20,8 @@ import ( func TestGeneratedInventoryHasTypedMethodForEveryCommand(t *testing.T) { commands := AllCommandMetadata() - if len(commands) != 101 { - t.Fatalf("generated commands = %d, want 101", len(commands)) + if len(commands) != 102 { + t.Fatalf("generated commands = %d, want 102", len(commands)) } clientType := reflect.TypeOf((*Client)(nil)) commandNames := make(map[string]struct{}, len(commands)) diff --git a/cmux-tui/bindings/go/raw/generated_commands.go b/cmux-tui/bindings/go/raw/generated_commands.go index 505ffa77f5fd..3748e12c8a25 100644 --- a/cmux-tui/bindings/go/raw/generated_commands.go +++ b/cmux-tui/bindings/go/raw/generated_commands.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// Mux protocol 12; IR SHA-256 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. package raw @@ -8961,6 +8961,27 @@ func (value *SetWindowTitleRequest) UnmarshalJSON(data []byte) error { type SetWindowTitleResult = EmptyResult +// ShutdownRequest is the exact shutdown wire payload. +type ShutdownRequest struct { +} + +func (value *ShutdownRequest) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode ShutdownRequest: expected object") + } + var fields struct { + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode ShutdownRequest: %w", err) + } + type wire ShutdownRequest + var decoded wire + *value = ShutdownRequest(decoded) + return nil +} + +type ShutdownResult = EmptyResult + // ShutdownDaemonRequest is the exact shutdown-daemon wire payload. type ShutdownDaemonRequest struct { Force *bool `json:"force,omitempty"` @@ -10738,6 +10759,12 @@ func (c *Client) SetWindowTitle(ctx context.Context, title string) error { return err } +// Shutdown sends shutdown. Protocol v9; authority local-admin. +func (c *Client) Shutdown(ctx context.Context) error { + err := c.requestGenerated(ctx, commandMetadata["shutdown"], "shutdown", nil, nil) + return err +} + // ShutdownDaemon sends shutdown-daemon. Protocol v9; authority local-admin. func (c *Client) ShutdownDaemon(ctx context.Context, generation string, pid uint32, options ShutdownDaemonOptions) (ShutdownDaemonResult, error) { var result ShutdownDaemonResult diff --git a/cmux-tui/bindings/go/raw/generated_events.go b/cmux-tui/bindings/go/raw/generated_events.go index b3f003eb3047..1c2f9dcc14d5 100644 --- a/cmux-tui/bindings/go/raw/generated_events.go +++ b/cmux-tui/bindings/go/raw/generated_events.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// Mux protocol 12; IR SHA-256 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. package raw diff --git a/cmux-tui/bindings/go/raw/generated_metadata.go b/cmux-tui/bindings/go/raw/generated_metadata.go index b90c86528d90..a6ff30efd29b 100644 --- a/cmux-tui/bindings/go/raw/generated_metadata.go +++ b/cmux-tui/bindings/go/raw/generated_metadata.go @@ -1,12 +1,12 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// Mux protocol 12; IR SHA-256 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. package raw const ( SDKSchemaVersion = 2 MuxProtocolVersion = 12 - SDKIRSHA256 = "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529" + SDKIRSHA256 = "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740" ) type Authority string @@ -152,6 +152,7 @@ var commandMetadata = map[string]CommandMetadata{ "set-split-ratio": {Name: "set-split-ratio", GoMethod: "SetSplitRatio", Authority: AuthorityControl, Since: 8, Capability: "", Stream: "", FieldSince: map[string]uint32{"transaction": 9}, FieldCapabilities: map[string]string{"transaction": "layout-undo-v1"}}, "set-viewport-pane-width": {Name: "set-viewport-pane-width", GoMethod: "SetViewportPaneWidth", Authority: AuthorityControl, Since: 9, Capability: "viewport-column-resize-v1", Stream: "", FieldSince: map[string]uint32{"transaction": 9}, FieldCapabilities: map[string]string{"transaction": "layout-undo-v1"}}, "set-window-title": {Name: "set-window-title", GoMethod: "SetWindowTitle", Authority: AuthorityControl, Since: 6, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, + "shutdown": {Name: "shutdown", GoMethod: "Shutdown", Authority: AuthorityLocalAdmin, Since: 9, Capability: "server-shutdown-v1", Stream: "", FieldSince: nil, FieldCapabilities: nil}, "shutdown-daemon": {Name: "shutdown-daemon", GoMethod: "ShutdownDaemon", Authority: AuthorityLocalAdmin, Since: 9, Capability: "", Stream: "", FieldSince: map[string]uint32{"force": 10}, FieldCapabilities: map[string]string{"force": "daemon-handoff-force-v1"}}, "sidebar-plugin": {Name: "sidebar-plugin", GoMethod: "SidebarPlugin", Authority: AuthorityFrontend, Since: 6, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, "split": {Name: "split", GoMethod: "Split", Authority: AuthorityControl, Since: 5, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, @@ -257,7 +258,7 @@ func ProfileInfo(name Profile) (ProfileMetadata, bool) { } func AllCommandMetadata() []CommandMetadata { - result := make([]CommandMetadata, 0, 101) + result := make([]CommandMetadata, 0, 102) result = append(result, cloneCommandMetadata(commandMetadata["apply-layout"])) result = append(result, cloneCommandMetadata(commandMetadata["attach-surface"])) result = append(result, cloneCommandMetadata(commandMetadata["browser-activate"])) @@ -348,6 +349,7 @@ func AllCommandMetadata() []CommandMetadata { result = append(result, cloneCommandMetadata(commandMetadata["set-split-ratio"])) result = append(result, cloneCommandMetadata(commandMetadata["set-viewport-pane-width"])) result = append(result, cloneCommandMetadata(commandMetadata["set-window-title"])) + result = append(result, cloneCommandMetadata(commandMetadata["shutdown"])) result = append(result, cloneCommandMetadata(commandMetadata["shutdown-daemon"])) result = append(result, cloneCommandMetadata(commandMetadata["sidebar-plugin"])) result = append(result, cloneCommandMetadata(commandMetadata["split"])) diff --git a/cmux-tui/bindings/go/raw/generated_presence_test.go b/cmux-tui/bindings/go/raw/generated_presence_test.go index 0edb1d21c715..f1cb5bb4c87c 100644 --- a/cmux-tui/bindings/go/raw/generated_presence_test.go +++ b/cmux-tui/bindings/go/raw/generated_presence_test.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// Mux protocol 12; IR SHA-256 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. package raw @@ -657,6 +657,28 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "lifecycle_ready", true, "true") }) + t.Run("IdentifyResult.ShutdownCleanup", func(t *testing.T) { + var missing IdentifyResult + if err := json.Unmarshal([]byte("{\"app\":\"cmux-tui\",\"daemon_handoff\":1,\"generation\":\"value\",\"pid\":1,\"protocol\":1,\"registry_id\":\"value\",\"session\":\"value\",\"terminal_revision\":1,\"version\":\"value\",\"workspace_revision\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.ShutdownCleanup != nil { + t.Fatal("ShutdownCleanup did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "shutdown_cleanup", false, "") + var nullValue IdentifyResult + if err := json.Unmarshal([]byte("{\"app\":\"cmux-tui\",\"daemon_handoff\":1,\"generation\":\"value\",\"pid\":1,\"protocol\":1,\"registry_id\":\"value\",\"session\":\"value\",\"terminal_revision\":1,\"version\":\"value\",\"workspace_revision\":1,\"shutdown_cleanup\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field shutdown_cleanup accepted null") + } + var presentValue IdentifyResult + if err := json.Unmarshal([]byte("{\"app\":\"cmux-tui\",\"daemon_handoff\":1,\"generation\":\"value\",\"pid\":1,\"protocol\":1,\"registry_id\":\"value\",\"session\":\"value\",\"terminal_revision\":1,\"version\":\"value\",\"workspace_revision\":1,\"shutdown_cleanup\":{\"degraded\":true,\"pending\":1,\"retrying\":true}}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.ShutdownCleanup == nil { + t.Fatal("ShutdownCleanup lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "shutdown_cleanup", true, "{\"degraded\":true,\"pending\":1,\"retrying\":true}") + }) t.Run("LayoutSplit.Split", func(t *testing.T) { var missing LayoutSplit if err := json.Unmarshal([]byte("{\"a\":{\"pane\":1,\"type\":\"leaf\"},\"b\":{\"pane\":1,\"type\":\"leaf\"},\"dir\":\"right\",\"ratio\":1.5,\"type\":\"split\"}"), &missing); err != nil { @@ -13808,6 +13830,24 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { t.Fatal("missing required field resizes decoded successfully") } }) + t.Run("ShutdownCleanupStatus.Degraded", func(t *testing.T) { + var decoded ShutdownCleanupStatus + if err := json.Unmarshal([]byte("{\"pending\":1,\"retrying\":true}"), &decoded); err == nil { + t.Fatal("missing required field degraded decoded successfully") + } + }) + t.Run("ShutdownCleanupStatus.Pending", func(t *testing.T) { + var decoded ShutdownCleanupStatus + if err := json.Unmarshal([]byte("{\"degraded\":true,\"retrying\":true}"), &decoded); err == nil { + t.Fatal("missing required field pending decoded successfully") + } + }) + t.Run("ShutdownCleanupStatus.Retrying", func(t *testing.T) { + var decoded ShutdownCleanupStatus + if err := json.Unmarshal([]byte("{\"degraded\":true,\"pending\":1}"), &decoded); err == nil { + t.Fatal("missing required field retrying decoded successfully") + } + }) t.Run("ShutdownDaemonResult.Accepted", func(t *testing.T) { var decoded ShutdownDaemonResult if err := json.Unmarshal([]byte("{\"generation\":\"value\",\"pid\":1}"), &decoded); err == nil { @@ -17585,6 +17625,24 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { t.Fatal("required non-nullable field resizes accepted null") } }) + t.Run("ShutdownCleanupStatus.Degraded", func(t *testing.T) { + var decoded ShutdownCleanupStatus + if err := json.Unmarshal([]byte("{\"degraded\":null,\"pending\":1,\"retrying\":true}"), &decoded); err == nil { + t.Fatal("required non-nullable field degraded accepted null") + } + }) + t.Run("ShutdownCleanupStatus.Pending", func(t *testing.T) { + var decoded ShutdownCleanupStatus + if err := json.Unmarshal([]byte("{\"degraded\":true,\"pending\":null,\"retrying\":true}"), &decoded); err == nil { + t.Fatal("required non-nullable field pending accepted null") + } + }) + t.Run("ShutdownCleanupStatus.Retrying", func(t *testing.T) { + var decoded ShutdownCleanupStatus + if err := json.Unmarshal([]byte("{\"degraded\":true,\"pending\":1,\"retrying\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field retrying accepted null") + } + }) t.Run("ShutdownDaemonResult.Accepted", func(t *testing.T) { var decoded ShutdownDaemonResult if err := json.Unmarshal([]byte("{\"accepted\":null,\"generation\":\"value\",\"pid\":1}"), &decoded); err == nil { @@ -21174,12 +21232,12 @@ func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { } const ( - generatedFieldShapeCount = 493 + generatedFieldShapeCount = 494 generatedOptionalNullableFieldCount = 331 generatedRequiredNullableFieldCount = 70 - generatedOptionalNonnullableFieldCount = 92 - generatedRequiredFieldCount = 664 - generatedRequiredNonnullableFieldCount = 594 + generatedOptionalNonnullableFieldCount = 93 + generatedRequiredFieldCount = 667 + generatedRequiredNonnullableFieldCount = 597 generatedConstrainedFieldCount = 87 generatedEncodedConstrainedFieldCount = 87 ) diff --git a/cmux-tui/bindings/go/raw/generated_types.go b/cmux-tui/bindings/go/raw/generated_types.go index 4707ea34e4ef..3efde5a9668b 100644 --- a/cmux-tui/bindings/go/raw/generated_types.go +++ b/cmux-tui/bindings/go/raw/generated_types.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// Mux protocol 12; IR SHA-256 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. package raw @@ -2649,6 +2649,7 @@ type IdentifyResult struct { Protocol uint32 `json:"protocol"` RegistryID string `json:"registry_id"` Session string `json:"session"` + ShutdownCleanup *ShutdownCleanupStatus `json:"shutdown_cleanup,omitempty"` TerminalRevision uint64 `json:"terminal_revision"` Version string `json:"version"` WorkspaceRevision uint64 `json:"workspace_revision"` @@ -2706,20 +2707,21 @@ func (value *IdentifyResult) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode IdentifyResult: expected object") } var fields struct { - App *IdentifyResultApp `json:"app"` - BuildCommit Presence[string] `json:"build_commit"` - Capabilities optionalNonNullJSON[[]string] `json:"capabilities"` - DaemonHandoff *IdentifyResultDaemonHandoff `json:"daemon_handoff"` - Generation *string `json:"generation"` - GhosttyCommit Presence[string] `json:"ghostty_commit"` - LifecycleReady optionalNonNullJSON[bool] `json:"lifecycle_ready"` - PID *uint32 `json:"pid"` - Protocol *uint32 `json:"protocol"` - RegistryID *string `json:"registry_id"` - Session *string `json:"session"` - TerminalRevision *uint64 `json:"terminal_revision"` - Version *string `json:"version"` - WorkspaceRevision *uint64 `json:"workspace_revision"` + App *IdentifyResultApp `json:"app"` + BuildCommit Presence[string] `json:"build_commit"` + Capabilities optionalNonNullJSON[[]string] `json:"capabilities"` + DaemonHandoff *IdentifyResultDaemonHandoff `json:"daemon_handoff"` + Generation *string `json:"generation"` + GhosttyCommit Presence[string] `json:"ghostty_commit"` + LifecycleReady optionalNonNullJSON[bool] `json:"lifecycle_ready"` + PID *uint32 `json:"pid"` + Protocol *uint32 `json:"protocol"` + RegistryID *string `json:"registry_id"` + Session *string `json:"session"` + ShutdownCleanup optionalNonNullJSON[ShutdownCleanupStatus] `json:"shutdown_cleanup"` + TerminalRevision *uint64 `json:"terminal_revision"` + Version *string `json:"version"` + WorkspaceRevision *uint64 `json:"workspace_revision"` } if err := json.Unmarshal(data, &fields); err != nil { return fmt.Errorf("decode IdentifyResult: %w", err) @@ -2778,6 +2780,12 @@ func (value *IdentifyResult) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode IdentifyResult: required field session is missing or null") } decoded.Session = *fields.Session + if fields.ShutdownCleanup.null { + return fmt.Errorf("decode IdentifyResult: non-nullable field shutdown_cleanup is null") + } + if fields.ShutdownCleanup.set { + decoded.ShutdownCleanup = &fields.ShutdownCleanup.value + } if fields.TerminalRevision == nil { return fmt.Errorf("decode IdentifyResult: required field terminal_revision is missing or null") } @@ -5796,6 +5804,42 @@ func (value *SetCellPixelsResult) UnmarshalJSON(data []byte) error { return nil } +type ShutdownCleanupStatus struct { + Degraded bool `json:"degraded"` + Pending uint64 `json:"pending"` + Retrying bool `json:"retrying"` +} + +func (value *ShutdownCleanupStatus) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode ShutdownCleanupStatus: expected object") + } + var fields struct { + Degraded *bool `json:"degraded"` + Pending *uint64 `json:"pending"` + Retrying *bool `json:"retrying"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode ShutdownCleanupStatus: %w", err) + } + type wire ShutdownCleanupStatus + var decoded wire + if fields.Degraded == nil { + return fmt.Errorf("decode ShutdownCleanupStatus: required field degraded is missing or null") + } + decoded.Degraded = *fields.Degraded + if fields.Pending == nil { + return fmt.Errorf("decode ShutdownCleanupStatus: required field pending is missing or null") + } + decoded.Pending = *fields.Pending + if fields.Retrying == nil { + return fmt.Errorf("decode ShutdownCleanupStatus: required field retrying is missing or null") + } + decoded.Retrying = *fields.Retrying + *value = ShutdownCleanupStatus(decoded) + return nil +} + type ShutdownDaemonResult struct { Accepted bool `json:"accepted"` Generation string `json:"generation"` diff --git a/cmux-tui/bindings/go/resource_api_test.go b/cmux-tui/bindings/go/resource_api_test.go index 97ebae02739b..ed9d9de09af2 100644 --- a/cmux-tui/bindings/go/resource_api_test.go +++ b/cmux-tui/bindings/go/resource_api_test.go @@ -4012,6 +4012,24 @@ func TestSlowConsumerQueueBoundsEndOnlyThatStream(t *testing.T) { } } +func TestRejectedStreamEndStillRecordsServerTermination(t *testing.T) { + route := &streamRoute{ + messages: make(chan streamMessage, 1), + accepting: true, + } + route.messages <- streamMessage{ + envelope: streamEnvelope{Type: "stream_item"}, + size: 1, + } + + if route.deliver(streamMessage{envelope: streamEnvelope{Type: "stream_end"}, size: 1}) { + t.Fatal("stream_end unexpectedly fit in the full delivery queue") + } + if !route.endedByServer() { + t.Fatal("observed stream_end was lost when its delivery queue was full") + } +} + func TestOversizedUnterminatedFrameIsBounded(t *testing.T) { clientSide, serverSide := net.Pipe() go func() { diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json b/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json index 3279a574c7b6..ca3337800c43 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json @@ -307,8 +307,8 @@ }, { "path": "Commands.java", - "sha256": "e98e5346be0295d401b8e64bf6fbb0fb63ab887bbbc24ffc00077789da098950", - "size": 24550 + "sha256": "df6b412951d573263d5db7fd7c8a3f8b5290882a816809992441d3edd7008a67", + "size": 24761 }, { "path": "ConfigReloadRequestedEvent.java", @@ -492,8 +492,8 @@ }, { "path": "GeneratedCmuxClient.java", - "sha256": "47e934ddfb7b255f04724f133ad5a33f6ac46f842ecd1ea0f5acd5b6c3c585c4", - "size": 23735 + "sha256": "d0445512347e4ee436dac682c235ced089d2b880d9553e3735c2d4d05d2348c9", + "size": 23912 }, { "path": "GetBrowserProviderRequest.java", @@ -542,8 +542,8 @@ }, { "path": "IdentifyResult.java", - "sha256": "01d12a8f161f06e624efd559b338cb7fccafe511aef96e0cc091b35098566702", - "size": 10786 + "sha256": "f7af3db6da02a3d097a80e23f7e4872101a07d2bd0ac11a8b917483573ca6db0", + "size": 11595 }, { "path": "IdsRequest.java", @@ -827,7 +827,7 @@ }, { "path": "Protocol.java", - "sha256": "1320a02c8e43614efb8ca0954a2968da4601de63798c871940c789176fc4b866", + "sha256": "19d374240c77c2fdda797741c48e00a9b34317da3d668ec7729189c816362448", "size": 4038 }, { @@ -1135,6 +1135,11 @@ "sha256": "5eac6261d6384c422c517ca06b9f05034be307b9b8747830dcbb7f1c1ad8e9f5", "size": 2021 }, + { + "path": "ShutdownCleanupStatus.java", + "sha256": "92f639034ae93e376ac02c7d935a5190ce177757cb32418af096cdaf7f74b545", + "size": 3389 + }, { "path": "ShutdownDaemonRequest.java", "sha256": "b60b647ed6dc21658f10c7428df62efe67b766309b4878336d6eef4d360ec051", @@ -1145,6 +1150,11 @@ "sha256": "60b706d96bb14141e7ec088ebdf27927c9a826d43a3af90b20930cb8a3066e90", "size": 2869 }, + { + "path": "ShutdownRequest.java", + "sha256": "cb80759f59ed02b4abf7bb7d8e4dfaad86ab02db49f929824ad3d958c919a1d0", + "size": 1325 + }, { "path": "SidebarPluginRequest.java", "sha256": "058ae4891509c162a08466666ac63a7f4b0a90d183b7f20e6b3a24730f3c2a4f", @@ -1457,7 +1467,7 @@ } ], "format": 1, - "ir_sha256": "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529", + "ir_sha256": "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740", "language": "java", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/Commands.java b/cmux-tui/bindings/java/src/com/cmux/raw/Commands.java index 16f0623430a4..eb09a60f6ef2 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/Commands.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/Commands.java @@ -100,6 +100,7 @@ private Commands() {} public static final CommandMetadata SET_SPLIT_RATIO = new CommandMetadata("set-split-ratio", Authority.CONTROL, 8, null, StreamKind.NONE, Map.ofEntries(Map.entry("transaction", 9L)), Map.ofEntries(Map.entry("transaction", "layout-undo-v1"))); public static final CommandMetadata SET_VIEWPORT_PANE_WIDTH = new CommandMetadata("set-viewport-pane-width", Authority.CONTROL, 9, "viewport-column-resize-v1", StreamKind.NONE, Map.ofEntries(Map.entry("transaction", 9L)), Map.ofEntries(Map.entry("transaction", "layout-undo-v1"))); public static final CommandMetadata SET_WINDOW_TITLE = new CommandMetadata("set-window-title", Authority.CONTROL, 6, null, StreamKind.NONE, Map.of(), Map.of()); + public static final CommandMetadata SHUTDOWN = new CommandMetadata("shutdown", Authority.LOCAL_ADMIN, 9, "server-shutdown-v1", StreamKind.NONE, Map.of(), Map.of()); public static final CommandMetadata SHUTDOWN_DAEMON = new CommandMetadata("shutdown-daemon", Authority.LOCAL_ADMIN, 9, null, StreamKind.NONE, Map.ofEntries(Map.entry("force", 10L)), Map.ofEntries(Map.entry("force", "daemon-handoff-force-v1"))); public static final CommandMetadata SIDEBAR_PLUGIN = new CommandMetadata("sidebar-plugin", Authority.FRONTEND, 6, null, StreamKind.NONE, Map.of(), Map.of()); public static final CommandMetadata SPLIT = new CommandMetadata("split", Authority.CONTROL, 5, null, StreamKind.NONE, Map.of(), Map.of()); @@ -205,6 +206,7 @@ private Commands() {} values.put("set-split-ratio", SET_SPLIT_RATIO); values.put("set-viewport-pane-width", SET_VIEWPORT_PANE_WIDTH); values.put("set-window-title", SET_WINDOW_TITLE); + values.put("shutdown", SHUTDOWN); values.put("shutdown-daemon", SHUTDOWN_DAEMON); values.put("sidebar-plugin", SIDEBAR_PLUGIN); values.put("split", SPLIT); diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/GeneratedCmuxClient.java b/cmux-tui/bindings/java/src/com/cmux/raw/GeneratedCmuxClient.java index 44de249ce35f..cc1c037bcf1e 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/GeneratedCmuxClient.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/GeneratedCmuxClient.java @@ -463,6 +463,11 @@ public final EmptyResult setWindowTitle(SetWindowTitleRequest request) throws Cm return EmptyResult.fromWire(result); } + public final EmptyResult shutdown() throws CmuxException { + Object result = execute(Commands.SHUTDOWN, Map.of()); + return EmptyResult.fromWire(result); + } + public final ShutdownDaemonResult shutdownDaemon(ShutdownDaemonRequest request) throws CmuxException { Object result = execute(Commands.SHUTDOWN_DAEMON, request.toWire()); return ShutdownDaemonResult.fromWire(result); diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/IdentifyResult.java b/cmux-tui/bindings/java/src/com/cmux/raw/IdentifyResult.java index e8fe2ab16907..eb72bc5252cd 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/IdentifyResult.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/IdentifyResult.java @@ -20,6 +20,7 @@ public final class IdentifyResult implements WireValue { private final long protocol; private final String registryId; private final String session; + private final Field shutdownCleanup; private final UInt64 terminalRevision; private final String version; private final UInt64 workspaceRevision; @@ -39,6 +40,7 @@ private IdentifyResult(Builder builder) { this.registryId = Wire.nonNull(builder.registryId, "registry_id"); if (!builder.sessionSet) throw new IllegalArgumentException("session is required"); this.session = Wire.nonNull(builder.session, "session"); + this.shutdownCleanup = builder.shutdownCleanup; if (!builder.terminalRevisionSet) throw new IllegalArgumentException("terminal_revision is required"); this.terminalRevision = Wire.nonNull(builder.terminalRevision, "terminal_revision"); if (!builder.versionSet) throw new IllegalArgumentException("version is required"); @@ -60,6 +62,7 @@ private IdentifyResult(Builder builder) { public long protocol() { return protocol; } public String registryId() { return registryId; } public String session() { return session; } + public Field shutdownCleanup() { return shutdownCleanup; } public UInt64 terminalRevision() { return terminalRevision; } public String version() { return version; } public UInt64 workspaceRevision() { return workspaceRevision; } @@ -97,6 +100,10 @@ public static IdentifyResult fromWire(Object value) { builder.registryId(Wire.string(rawRegistryId, "IdentifyResult.registry_id")); Object rawSession = Wire.required(object, "session"); builder.session(Wire.string(rawSession, "IdentifyResult.session")); + Object rawShutdownCleanup = Wire.optional(object, "shutdown_cleanup"); + if (!Wire.isMissing(rawShutdownCleanup)) { + builder.shutdownCleanup(ShutdownCleanupStatus.fromWire(rawShutdownCleanup)); + } Object rawTerminalRevision = Wire.required(object, "terminal_revision"); builder.terminalRevision(Wire.uint64(rawTerminalRevision, "IdentifyResult.terminal_revision")); Object rawVersion = Wire.required(object, "version"); @@ -120,6 +127,7 @@ public Map toWire() { Wire.put(object, "protocol", protocol); Wire.put(object, "registry_id", registryId); Wire.put(object, "session", session); + Wire.put(object, "shutdown_cleanup", shutdownCleanup); Wire.put(object, "terminal_revision", terminalRevision); Wire.put(object, "version", version); Wire.put(object, "workspace_revision", workspaceRevision); @@ -129,11 +137,11 @@ public Map toWire() { @Override public boolean equals(Object other) { if (!(other instanceof IdentifyResult that)) return false; - return Objects.equals(buildCommit, that.buildCommit) && Objects.equals(capabilities, that.capabilities) && Objects.equals(generation, that.generation) && Objects.equals(ghosttyCommit, that.ghosttyCommit) && Objects.equals(lifecycleReady, that.lifecycleReady) && Objects.equals(pid, that.pid) && Objects.equals(protocol, that.protocol) && Objects.equals(registryId, that.registryId) && Objects.equals(session, that.session) && Objects.equals(terminalRevision, that.terminalRevision) && Objects.equals(version, that.version) && Objects.equals(workspaceRevision, that.workspaceRevision); + return Objects.equals(buildCommit, that.buildCommit) && Objects.equals(capabilities, that.capabilities) && Objects.equals(generation, that.generation) && Objects.equals(ghosttyCommit, that.ghosttyCommit) && Objects.equals(lifecycleReady, that.lifecycleReady) && Objects.equals(pid, that.pid) && Objects.equals(protocol, that.protocol) && Objects.equals(registryId, that.registryId) && Objects.equals(session, that.session) && Objects.equals(shutdownCleanup, that.shutdownCleanup) && Objects.equals(terminalRevision, that.terminalRevision) && Objects.equals(version, that.version) && Objects.equals(workspaceRevision, that.workspaceRevision); } @Override - public int hashCode() { return Objects.hash(buildCommit, capabilities, generation, ghosttyCommit, lifecycleReady, pid, protocol, registryId, session, terminalRevision, version, workspaceRevision); } + public int hashCode() { return Objects.hash(buildCommit, capabilities, generation, ghosttyCommit, lifecycleReady, pid, protocol, registryId, session, shutdownCleanup, terminalRevision, version, workspaceRevision); } @Override public String toString() { return "IdentifyResult" + toWire(); } @@ -153,6 +161,7 @@ public static final class Builder { private boolean registryIdSet; private String session; private boolean sessionSet; + private Field shutdownCleanup = Field.omitted(); private UInt64 terminalRevision; private boolean terminalRevisionSet; private String version; @@ -201,6 +210,10 @@ public Builder session(String value) { this.sessionSet = true; return this; } + public Builder shutdownCleanup(ShutdownCleanupStatus value) { + this.shutdownCleanup = Field.of(value); + return this; + } public Builder terminalRevision(UInt64 value) { this.terminalRevision = value; this.terminalRevisionSet = true; diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java b/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java index d8de0223022c..d0b085d13a73 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java @@ -9,7 +9,7 @@ public final class Protocol { public static final String SDK_VERSION = "1.0.0"; public static final int VERSION = 12; public static final int SCHEMA_VERSION = 2; - public static final String IR_SHA256 = "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529"; + public static final String IR_SHA256 = "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740"; private Protocol() {} public static ProtocolEvent decodeEvent(Object value) { diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/ShutdownCleanupStatus.java b/cmux-tui/bindings/java/src/com/cmux/raw/ShutdownCleanupStatus.java new file mode 100644 index 000000000000..5a002c53eb62 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/ShutdownCleanupStatus.java @@ -0,0 +1,91 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class ShutdownCleanupStatus implements WireValue { + private final boolean degraded; + private final UInt64 pending; + private final boolean retrying; + + private ShutdownCleanupStatus(Builder builder) { + if (!builder.degradedSet) throw new IllegalArgumentException("degraded is required"); + this.degraded = builder.degraded; + if (!builder.pendingSet) throw new IllegalArgumentException("pending is required"); + this.pending = Wire.nonNull(builder.pending, "pending"); + if (!builder.retryingSet) throw new IllegalArgumentException("retrying is required"); + this.retrying = builder.retrying; + } + + public static Builder builder() { return new Builder(); } + + public boolean degraded() { return degraded; } + public UInt64 pending() { return pending; } + public boolean retrying() { return retrying; } + + public static ShutdownCleanupStatus fromWire(Object value) { + Map object = Wire.object(value, "ShutdownCleanupStatus"); + Builder builder = builder(); + Object rawDegraded = Wire.required(object, "degraded"); + builder.degraded(Wire.bool(rawDegraded, "ShutdownCleanupStatus.degraded")); + Object rawPending = Wire.required(object, "pending"); + builder.pending(Wire.uint64(rawPending, "ShutdownCleanupStatus.pending")); + Object rawRetrying = Wire.required(object, "retrying"); + builder.retrying(Wire.bool(rawRetrying, "ShutdownCleanupStatus.retrying")); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "degraded", degraded); + Wire.put(object, "pending", pending); + Wire.put(object, "retrying", retrying); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof ShutdownCleanupStatus that)) return false; + return Objects.equals(degraded, that.degraded) && Objects.equals(pending, that.pending) && Objects.equals(retrying, that.retrying); + } + + @Override + public int hashCode() { return Objects.hash(degraded, pending, retrying); } + + @Override + public String toString() { return "ShutdownCleanupStatus" + toWire(); } + + public static final class Builder { + private Boolean degraded; + private boolean degradedSet; + private UInt64 pending; + private boolean pendingSet; + private Boolean retrying; + private boolean retryingSet; + + public Builder degraded(boolean value) { + this.degraded = value; + this.degradedSet = true; + return this; + } + public Builder pending(UInt64 value) { + this.pending = value; + this.pendingSet = true; + return this; + } + public Builder retrying(boolean value) { + this.retrying = value; + this.retryingSet = true; + return this; + } + public ShutdownCleanupStatus build() { return new ShutdownCleanupStatus(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/ShutdownRequest.java b/cmux-tui/bindings/java/src/com/cmux/raw/ShutdownRequest.java new file mode 100644 index 000000000000..c6f6ea459f83 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/ShutdownRequest.java @@ -0,0 +1,50 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +/** Immutable shutdown request. Protocol v9; authority: local-admin. */ +public final class ShutdownRequest implements WireValue { + + private ShutdownRequest(Builder builder) { + } + + public static Builder builder() { return new Builder(); } + + + public static ShutdownRequest fromWire(Object value) { + Map object = Wire.object(value, "ShutdownRequest"); + Builder builder = builder(); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof ShutdownRequest that)) return false; + return true; + } + + @Override + public int hashCode() { return Objects.hash(); } + + @Override + public String toString() { return "ShutdownRequest" + toWire(); } + + public static final class Builder { + + public ShutdownRequest build() { return new ShutdownRequest(this); } + } +} diff --git a/cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java b/cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java index 078dcd9f53ae..83f42af22cf9 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java @@ -20,7 +20,7 @@ public final class GeneratedCoverageTest { public static void main(String[] args) throws Exception { check(Protocol.VERSION == 12, "protocol version"); check("1.0.0".equals(Protocol.SDK_VERSION), "SDK release version"); - check(Commands.ALL.size() == 101, "all 101 commands generated"); + check(Commands.ALL.size() == 102, "all 102 commands generated"); check(Events.ALL.size() == 46, "all 46 events generated"); Map methods = Arrays.stream(GeneratedCmuxClient.class.getDeclaredMethods()) diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json index 8ead227c7bf2..6454857ae909 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json @@ -7,32 +7,32 @@ }, { "path": "_schema.py", - "sha256": "fd64e0492966217568e91bdfd9d0561eca1e389c080ca9e8f7f387b170721656", - "size": 153613 + "sha256": "36ee7b1dbf0c6750a3e3a454ac6cbefa04661b03e145676c3e1daf6aa244aa2a", + "size": 154491 }, { "path": "client.py", - "sha256": "b215418411a8b95d252261d87a7cbd0158f2313b5d9f03346cbab6d26fa8f200", - "size": 37634 + "sha256": "0dbd24fc45977c50cc2221b69dbc0ca5a8af3857e3d16241e97a52b1bbb9dcbd", + "size": 37811 }, { "path": "codec.py", - "sha256": "780b40c624b5af57c059c89e81c61f4b6a85a0560c8e998bb5d4b75d65a324db", - "size": 28156 + "sha256": "d62b3ecf39cb0205cc42fd30ced3747844b17ef16927cf75a18833410f0b18c6", + "size": 28278 }, { "path": "metadata.py", - "sha256": "69574239dccdf94b58dd67ece2d9b9fc66f7f8d56dbcd591316cf7a6af9b60b6", - "size": 44652 + "sha256": "0ecb78a59e55d701e0b644c712fdf01ef3fe11bb0d8096c0104be69c6a14383c", + "size": 44837 }, { "path": "models.py", - "sha256": "8452a8402f96a6193bd2f497e6b7a3e19a02091732652e8bf589a4c98f5c3849", - "size": 84565 + "sha256": "edd79f40a73bd2e76b39783c59ca0b51ceb0dc657ecc1644f91a6bbfd390a6bb", + "size": 85016 } ], "format": 1, - "ir_sha256": "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529", + "ir_sha256": "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740", "language": "python", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py b/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py index 34881b3309a4..94cba765b6d3 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py @@ -5,4 +5,4 @@ import json -SCHEMA = json.loads('{"$schema":"./sdk-schema.schema.json","commands":{"apply-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["layout must contain at least one leaf or stack member.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ApplyLayoutResult"},"since":6,"stream":null},"attach-surface":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows must be supplied together.","Browser surfaces reject mode:render."],"fields":{"cols":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"mode":{"default":"bytes","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["bytes","render"]}},"rows":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["browser-state","colors-changed","detached","frame","notification","output","overflow","render-delta","render-state","resized","scroll-changed","vt-state"],"kind":"attach","mode_field":"mode","modes":{"browser":["browser-state","frame","notification","scroll-changed","overflow","detached"],"bytes":["vt-state","output","resized","colors-changed","scroll-changed","notification","overflow","detached"],"render":["render-state","render-delta","scroll-changed","overflow","detached"]},"ordering":"Initial state precedes the command response. Later surface events preserve order. A resized replay replaces the previous byte mirror; overflow ends that surface stream.","terminal_event":"detached"}},"browser-activate":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-back":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-forward":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-frame-presented":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Acknowledges the exact rendered browser frame for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-insert-text":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up"]}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key-press":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","One request preserves the atomic press sequence through the bounded input queue."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-mouse":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; coordinates are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-mouse-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; coordinates are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-navigate":{"authority":"frontend","capability":null,"constraints":["Queue acknowledgement only; observe browser-state for outcome.","Navigation is latest-wins."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-reload":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; values are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; values are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"clear-history":{"authority":"control","capability":"clear-history-v1","constraints":["PTY surfaces only.","Failed responses classify error_delivery as known-not-delivered or ambiguous."],"request":{"additional_properties":false,"fields":{"fallback_key":{"capability":"clear-history-key-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"TerminalKeyInput"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"clear-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"close-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the close."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"close-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"CloseTerminalResult"},"since":9,"stream":null},"close-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"copy":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"CopyResult"},"since":6,"stream":null},"create-surface-with-receipt":{"authority":"control","capability":"creation-receipts-v1","constraints":["Repeating one origin and receipt with identical fields returns the original creation result.","A new idempotency_key is valid only when durable creation resolution instructs retry_new_idempotency_key."],"request":{"additional_properties":false,"constraints":["operation is one of new-tab, run-command, new-browser-tab, new-workspace, new-screen, new-pane, new-pane-right, split-right, or split-down.","Each operation admits only its documented selector and option fields.","idempotency_key names one execution attempt and defaults to receipt.","cols and rows must be supplied together."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"idempotency_key":{"capability":"creation-attempt-keys-v1","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"operation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"receipt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"selector_fallbacks":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"ResourceSelectors"},"kind":"array","max_items":7}},"selectors":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ResourceSelectors"}},"url":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"width":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"JsonValue"},"since":10,"stream":null},"create-terminal":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; when both are supplied they must identify the same workspace.","argv and command are mutually exclusive and must be nonempty when supplied.","cols and rows must be supplied together.","origin and mutation_id are either both present or both absent.","terminal_id may be supplied only when origin and mutation_id are both present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"terminal_id":{"constraints":[{"format":"32-character lowercase UUIDv4 hex without dashes","pattern":"^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$"}],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalPlacement"},"since":7,"stream":null},"create-workspace":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","At most 4096 live workspaces may exist; tombstoned keys cannot be reused."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"constraints":[{"format":"lowercase canonical UUID"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":7,"stream":null},"detach-attached-view":{"authority":"frontend","capability":"view-attachment-detach-v1","constraints":["The command closes only the named view stream and releases its size contribution.","A retired lease returns outcome:superseded."],"request":{"additional_properties":false,"fields":{"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewOutcomeResult"},"since":10,"stream":null},"detach-client":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"export-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ExportLayoutResult"},"since":6,"stream":null},"focus-direction":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"FocusDirectionResult"},"since":6,"stream":null},"focus-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"get-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["Provider endpoints and targets are disclosed only over a trusted local transport; bearer credentials are accepted only during registration and are never returned.","Automation must select a target by stable tab id instead of treating CDP discovery as topology authority."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderSnapshot"},"since":10,"stream":null},"get-cell-pixels":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"GetCellPixelsResult"},"since":6,"stream":null},"get-frontend-projection":{"authority":"control","capability":null,"constraints":["Each identifier is nonempty, contains no control character, and is at most 128 bytes."],"request":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"identify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"IdentifyResult"},"since":5,"stream":null},"ids":{"authority":"control","capability":null,"constraints":["Short ids are snapshot-local labels; command parameters accept numeric ids only."],"request":{"additional_properties":false,"fields":{"kind":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}}},"kind":"object"},"result":{"kind":"ref","name":"IdsResult"},"since":6,"stream":null},"journal-frontend-event":{"authority":"control","capability":"frontend-journal-v1","constraints":["The server derives producer identity from the authenticated control client."],"request":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"FrontendJournalEvent"}}},"kind":"object"},"result":{"additional_properties":false,"fields":{"committed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":10,"stream":null},"list-agents":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"state":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"AgentState"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ListAgentsResult"},"since":6,"stream":null},"list-clients":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"items":{"kind":"ref","name":"ClientInfo"},"kind":"array"},"since":6,"stream":null},"list-terminals":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"ListTerminalsResult"},"since":9,"stream":null},"list-workspaces":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"Tree"},"since":5,"stream":null},"mark-workspaces-provider-managed":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Authority must match the value provisioned before this mux generation accepted control clients."],"request":{"additional_properties":false,"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"mint-terminal-renderer":{"authority":"frontend","capability":null,"constraints":["Only terminal-host-backed PTYs can mint one-use renderer credentials."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":9,"stream":null},"mint-terminal-renderer-by-terminal":{"authority":"frontend","capability":null,"constraints":["The terminal resource ID is resolved atomically to the live terminal-host-backed PTY before minting a one-use renderer credential."],"request":{"additional_properties":false,"fields":{"terminal":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":11,"stream":null},"move-tab":{"authority":"control","capability":null,"constraints":["An out-of-range index clamps to the destination end."],"request":{"additional_properties":false,"fields":{"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"move-terminal":{"authority":"control","capability":null,"constraints":["A move to the current workspace still commits a terminal revision with changed:false."],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"MoveTerminalResult"},"since":9,"stream":null},"move-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"new-browser-tab":{"authority":"control","capability":null,"constraints":["Bootstrap and navigation failures are asynchronous browser-state outcomes."],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-pane-right":{"authority":"control","capability":"viewport-splits-v1","constraints":[],"request":{"additional_properties":false,"constraints":["Omitted width defaults to two thirds.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"notify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"level":{"default":"info","nullable":true,"presence":"optional","type":{"kind":"ref","name":"NotificationLevel"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"title":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"NotifyResult"},"since":6,"stream":null},"pairing-response":{"authority":"local-admin","capability":null,"constraints":["The request id must identify a live, unexpired pairing challenge."],"request":{"additional_properties":false,"fields":{"approve":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"pane-neighbor":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"PaneNeighborResult"},"since":6,"stream":null},"ping":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"PingResult"},"since":6,"stream":null},"process-info":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProcessInfoResult"},"since":6,"stream":null},"put-frontend-projection":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","Serialized projection must be at most 1048576 bytes."],"fields":{"expected_generation":{"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_projection_revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"read-screen":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScreenResult"},"since":5,"stream":null},"read-scrollback":{"authority":"control","capability":null,"constraints":["PTY surfaces only; row indexes are snapshot-relative and not durable."],"request":{"additional_properties":false,"fields":{"count":{"constraints":[{"maximum":65535,"minimum":0}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScrollbackResult"},"since":7,"stream":null},"register-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["The lease is scoped to the trusted local control connection and is released on disconnect.","The endpoint must be an explicit loopback ws URL with no credentials or fragment.","Bearer authentication is optional and sends the token only in the CDP WebSocket upgrade Authorization header.","Each registration replaces that connection\'s complete target set; target ids are never journaled."],"request":{"additional_properties":false,"fields":{"authentication":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"BrowserProviderAuthentication"}},"bearer_token":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"provider_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"targets":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"BrowserProviderTarget"},"kind":"array"}}},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderSnapshot"},"since":10,"stream":null},"release-attached-view-size":{"authority":"frontend","capability":"view-attachment-lease-v1","constraints":["The attach stream remains live for cached rendering."],"request":{"additional_properties":false,"fields":{"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewOutcomeResult"},"since":10,"stream":null},"release-surface-size":{"authority":"control","capability":null,"constraints":["An absent lease is a successful no-op."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"reload-config":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"additional_properties":false,"fields":{"path":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"reloaded":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":6,"stream":null},"rename-pane":{"authority":"control","capability":null,"constraints":["An empty name clears the pane name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the rename."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"rename-screen":{"authority":"control","capability":null,"constraints":["An empty name clears the screen name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-surface":{"authority":"control","capability":null,"constraints":["An empty name clears the surface name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"report-agent":{"authority":"control","capability":null,"constraints":["A stored hook report outranks later socket reports until another hook report or surface close."],"request":{"additional_properties":false,"fields":{"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReportAgentResult"},"since":6,"stream":null},"resize-attached-view":{"authority":"frontend","capability":"view-attachment-lease-v1","constraints":["The lease must belong to this connection and surface.","A retired lease returns outcome:superseded without changing replacement views."],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewResizeResult"},"since":10,"stream":null},"resize-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ResizeSurfaceResult"},"since":5,"stream":null},"resolve-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"ResolveTerminalResult"},"since":9,"stream":null},"run":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of argv and command must be supplied.","pane and new_workspace:true are mutually exclusive.","key is valid only with new_workspace:true.","cols and rows affect sizing only when both are present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"key":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"new_workspace":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"RunResult"},"since":6,"stream":null},"scroll-surface":{"authority":"control","capability":null,"constraints":["PTY surfaces only; negative values scroll up."],"request":{"additional_properties":false,"fields":{"delta":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both are present, UTF-8 text bytes precede decoded bytes."],"fields":{"bytes":{"constraints":[{"encoding":"standard base64"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"paste":{"default":false,"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send-key":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"keys":{"constraints":[{"syntax":"lowercase modifier+key chords"}],"nullable":false,"presence":"required","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-cell-pixels":{"authority":"frontend","capability":null,"constraints":["Accepted browser resizes complete asynchronously."],"request":{"additional_properties":false,"fields":{"height_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"width_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SetCellPixelsResult"},"since":6,"stream":null},"set-client-info":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"capabilities":{"constraints":["Advertises additive client capabilities for this connection."],"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"kind":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-client-sizing":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["exclusive:true requires client and enabled:true.","Omitting client is valid only with enabled:true and restores all clients for the surface."],"fields":{"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"enabled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"exclusive":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"set-default-colors":{"authority":"control","capability":null,"constraints":["Color strings are exactly #rrggbb.","With complete:true, absent optional values reset to built-in defaults."],"request":{"additional_properties":false,"fields":{"bg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"complete":{"default":false,"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-split-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":8,"stream":null},"set-viewport-pane-width":{"authority":"control","capability":"viewport-column-resize-v1","constraints":["width must be finite."],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"set-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"title":{"constraints":["C0 controls are sanitized before OSC output."],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"shutdown-daemon":{"authority":"local-admin","capability":null,"constraints":["pid and generation must match the latest identify result.","force bypasses native-browser ownership only; the identity fence and trusted-local authority still apply.","Clients must require daemon-handoff-force-v1 before sending force:true.","The daemon exits only after the success response is queued."],"request":{"additional_properties":false,"fields":{"force":{"capability":"daemon-handoff-force-v1","default":false,"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"ShutdownDaemonResult"},"since":9,"stream":null},"sidebar-plugin":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"relaunch":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SidebarPluginResult"},"since":6,"stream":null},"split":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"subscribe":{"authority":"frontend","capability":null,"constraints":["subscribe sends no initial tree snapshot.","surface filtering occurs before the bounded mailbox."],"request":{"additional_properties":false,"fields":{"surface":{"capability":"surface-subscribe-filter","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"Id"}},"tree_events":{"default":"coarse","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["coarse","deltas"]}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["agent-changed","bell","client-attached","client-changed","client-detached","client-list-invalidated","config-reload-requested","empty","frontend-projection-changed","layout-changed","notification","overflow","pairing-requested","pairing-resolved","pane-added","pane-closed","screen-added","screen-closed","screen-renamed","scroll-changed","status","surface-exited","surface-output","surface-resize-failed","surface-resized","tab-added","tab-closed","tab-renamed","terminal-registry-changed","title-changed","tree-changed","window-title-requested","workspace-added","workspace-closed","workspace-moved","workspace-renamed"],"kind":"subscribe","mode_field":"tree_events","modes":{"coarse":["tree-changed"],"deltas":["workspace-added","workspace-closed","workspace-renamed","workspace-moved","screen-added","screen-closed","screen-renamed","pane-added","pane-closed","tab-added","tab-closed","tab-renamed","tree-changed"]},"ordering":"Response and event objects may interleave. Events preserve enqueue order per subscription. Delta workspace revisions are serialized in durable commit order; overflow ends the stream and requires resubscribe plus snapshot.","terminal_event":null}},"swap-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of dir and target must be supplied."],"fields":{"dir":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"target":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"terminal-events":{"authority":"control","capability":null,"constraints":["Consumers apply only contiguous revisions for one registry_id and generation."],"request":{"additional_properties":false,"fields":{"after_revision":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalEventsResult"},"since":9,"stream":null},"undo-layout":{"authority":"control","capability":"layout-undo-v1","constraints":["Clients must reject incomplete or contradictory result variants."],"request":{"additional_properties":false,"constraints":["confirm_close requires the exact preview revision."],"fields":{"confirm_close":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"LayoutUndoResult"},"since":9,"stream":null},"unregister-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["Only the calling connection\'s provider lease is removed."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderUnregisterResult"},"since":10,"stream":null},"vt-state":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"VtStateResult"},"since":5,"stream":null},"wait-for":{"authority":"control","capability":null,"constraints":["Blocks subsequent requests on this connection; SDKs should use a dedicated connection."],"request":{"additional_properties":false,"fields":{"pattern":{"constraints":[{"syntax":"Rust regex"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"timeout_ms":{"description":"Zero performs one immediate check.","nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"WaitForResult"},"since":6,"stream":null},"zoom-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"mode":{"default":"toggle","nullable":true,"presence":"optional","type":{"kind":"enum","values":["toggle","on","off"]}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ZoomPaneResult"},"since":6,"stream":null}},"events":{"agent-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"agent-changed"}},"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":11,"streams":["subscribe"]},"bell":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"bell"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"browser-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"browser-state"}},"frame":{"description":"The initial browser-state includes the latest frame when one exists; later state updates omit it.","nullable":true,"presence":"optional","type":{"kind":"ref","name":"BrowserFrame"}},"frames_stalled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"status":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["starting","live","failed"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"client-attached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-attached"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["unix","ws"]}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-changed"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-detached"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-list-invalidated":{"capability":null,"emission":"serialized-never-emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-list-invalidated"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"colors-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"CursorStyle"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"colors-changed"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"palette":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"surface":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"config-reload-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"config-reload-requested"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"detached"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte","attach-render","attach-browser"]},"empty":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"empty"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"frame":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frame"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"frontend-projection-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frontend-projection-changed"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"graphics-status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["kitty-image-budget-worker-start-failed carries error.","kitty-image-budget-update-failed carries retry_exhausted and summary.","cell-pixel-update-retries-exhausted carries attempts, remaining, cell_width, and cell_height."],"fields":{"attempts":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_height":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"graphics-status"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["kitty-image-budget-worker-start-failed","kitty-image-budget-update-failed","cell-pixel-update-retries-exhausted"]}},"remaining":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"retry_exhausted":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"summary":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":10,"streams":["subscribe"]},"layout-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"layout-changed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"notification":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"notification"}},"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-browser"]},"output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"overflow":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["scope and surface are either both present for attach overflow or both absent for subscribe overflow."],"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"overflow"}},"scope":{"nullable":false,"presence":"optional","type":{"kind":"literal","value":"surface"}},"surface":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"pairing-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-requested"}},"expires_in":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"peer":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pairing-resolved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-resolved"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pane-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"pane-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"render-delta":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["size is present if and only if the surface resized; every resize has full:true."],"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-delta"}},"full":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphicsDelta"}},"history_epoch":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"render-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-state"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphics"}},"history_epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["At least one of replay and data is present; replay is canonical from protocol 7."],"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"description":"Protocol 6 compatibility field.","nullable":false,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resized"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"replay":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Base64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"screen-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-renamed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"scroll-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"at_bottom":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"scroll-changed"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"status"}},"message":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-exited":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-exited"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-resize-failed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resize-failed"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"surface-resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resized"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tab-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-renamed"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"terminal-registry-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-registry-changed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"refetch":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-events-or-list-terminals"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"title-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"title-changed"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tree-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tree-changed"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"vt-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":6,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"vt-state"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"window-title-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"window-title-requested"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"workspace-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-added"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-closed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-moved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-moved"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-renamed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]}},"ir_sha256":"0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529","profiles":{"control":{"description":"Base authenticated session-control commands available to ordinary SDK clients.","inherits":[]},"frontend":{"description":"Rendering, input, presentation, subscribe, and attach commands.","inherits":["control"]},"local-admin":{"description":"Trusted local administration commands.","inherits":["control"],"transport":"Unix-classified transport, including direct Unix and the current stdio relay"},"provider-authority":{"description":"Provider-owned workspace mutation commands.","inherits":["control"],"requires_authority":true}},"protocol":{"id_type":"uint64","javascript_id_policy":"All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.","name":"cmux-tui-mux","version":12},"schema_version":2,"types":{"AgentRecord":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"AgentReportSource":{"kind":"enum","values":["socket","hook"]},"AgentSource":{"kind":"enum","values":["detected","socket","hook"]},"AgentState":{"kind":"enum","values":["working","blocked","idle","done","unknown"]},"AppliedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ApplyLayoutResult":{"additional_properties":false,"fields":{"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AppliedPane"},"kind":"array"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"AttachedViewOutcomeResult":{"additional_properties":false,"fields":{"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ViewAttachmentOutcome"}}},"kind":"object"},"AttachedViewResizeResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ViewAttachmentOutcome"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"Base64":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"BrowserFrame":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"BrowserProviderAuthentication":{"kind":"enum","values":["none","bearer"]},"BrowserProviderSnapshot":{"additional_properties":false,"constraints":["available is true exactly when provider_id, endpoint, authentication, and clients are present.","Provider bearer tokens are accepted only during registration and are never returned."],"fields":{"authentication":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"BrowserProviderAuthentication"}},"available":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"clients":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"endpoint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"provider_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"targets":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"BrowserProviderTarget"},"kind":"array"}}},"kind":"object"},"BrowserProviderTarget":{"additional_properties":false,"fields":{"tab_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"target_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"BrowserProviderUnregisterResult":{"additional_properties":false,"fields":{"removed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"CellPixelFailure":{"additional_properties":false,"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelResize":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"reservation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelSurface":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"ClientInfo":{"additional_properties":false,"fields":{"attached":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"connected_seconds":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"self":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"sizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ClientSize"},"kind":"array"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ClientTransport"}}},"kind":"object"},"ClientSize":{"additional_properties":false,"fields":{"cols":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"size_participating":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ClientTransport":{"kind":"enum","values":["local","unix","ws"]},"CloseTerminalResult":{"additional_properties":false,"fields":{"already_closed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"closed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ColorHex":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"CopyResult":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"CursorStyle":{"kind":"enum","values":["block","underline","bar"]},"DeadPane":{"additional_properties":false,"fields":{"dead":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"DeclarativeLayout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"command":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"EmptyResult":{"additional_properties":false,"fields":{},"kind":"object"},"ExportLayoutResult":{"additional_properties":false,"fields":{"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ExportedPane"},"kind":"array"}}},"kind":"object"},"ExportedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}}},"kind":"object"},"FocusDirectionResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"FrontendFocusTarget":{"kind":"enum","values":["pane","machine_rail","workspace_rail","tabs_rail","projection_rail"]},"FrontendJournalEvent":{"kind":"tagged_union","tag":"kind","variants":{"focus":{"additional_properties":false,"fields":{"content_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"focus"}},"pane_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"screen_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"tab_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"target":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"FrontendFocusTarget"}},"workspace_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"resize":{"additional_properties":false,"fields":{"cell_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resize"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"viewport":{"additional_properties":false,"fields":{"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"viewport"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"settled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"target":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"}}},"FrontendProjection":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"replayed":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"GetCellPixelsResult":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelSurface"},"kind":"array"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"Id":{"kind":"alias","target":{"kind":"scalar","name":"uint64"}},"IdMapping":{"additional_properties":false,"fields":{"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"IdentifyResult":{"additional_properties":false,"fields":{"app":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"cmux-tui"}},"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"capabilities":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"daemon_handoff":{"nullable":false,"presence":"required","since":9,"type":{"kind":"literal","value":1}},"generation":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"lifecycle_ready":{"default":true,"nullable":false,"presence":"optional","since":12,"type":{"kind":"scalar","name":"boolean"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"registry_id":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"session":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"uint64"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_revision":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"IdsResult":{"additional_properties":false,"fields":{"ids":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"IdMapping"},"kind":"array"}}},"kind":"object"},"JsonValue":{"kind":"opaque_json","reason":"The wire field intentionally carries a frontend-authored or runtime-authored arbitrary JSON document."},"KittyGraphicsState":{"additional_properties":false,"fields":{"alternate_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"alternate_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"inflight_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"placements":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"primary_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"primary_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"replay_cursor_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"KittyImageAlias":{"additional_properties":false,"fields":{"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_number":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"Layout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"maximum":0.95,"minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"description":"Stable for the lifetime of this split node.","nullable":false,"presence":"optional","since":8,"type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"LayoutUndoConfirmationRequired":{"additional_properties":false,"fields":{"closes_panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"confirmation_required":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":false}}},"kind":"object"},"LayoutUndoResult":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LayoutUndoUndone"},{"kind":"ref","name":"LayoutUndoConfirmationRequired"}]},"LayoutUndoUndone":{"additional_properties":false,"fields":{"confirmation_required":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"literal","value":false}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"ListAgentsResult":{"additional_properties":false,"fields":{"agents":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AgentRecord"},"kind":"array"}}},"kind":"object"},"ListTerminalsResult":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"terminals":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRecord"},"kind":"array"}}},"kind":"object"},"LivePane":{"additional_properties":false,"fields":{"active_tab":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"focused_at":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"tabs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Tab"},"kind":"array"}}},"kind":"object"},"MintTerminalRendererResult":{"additional_properties":false,"fields":{"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"incarnation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"protocol_version":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"uint16"}},"rights":{"constraints":[{"current_value":7}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"token":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"MoveTerminalResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"NotificationLevel":{"kind":"enum","values":["info","warning","error"]},"NotificationMarker":{"additional_properties":false,"fields":{"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"unread":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"NotifyResult":{"additional_properties":false,"fields":{"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Pane":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LivePane"},{"kind":"ref","name":"DeadPane"}]},"PaneDirection":{"kind":"enum","values":["left","right","up","down"]},"PaneNeighborResult":{"additional_properties":false,"fields":{"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"PingResult":{"additional_properties":false,"fields":{"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ok":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ProcessInfoResult":{"additional_properties":false,"fields":{"command":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"cwd":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"ProviderWorkspaceMutationResult":{"additional_properties":false,"fields":{"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ReadScreenResult":{"additional_properties":false,"fields":{"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ReadScrollbackResult":{"additional_properties":false,"fields":{"epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"total":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderCursor":{"additional_properties":false,"fields":{"blink":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"color":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"style":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"CursorStyle"}},"visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderGraphicFormat":{"kind":"enum","values":["rgb","rgba"]},"RenderGraphicImage":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"format":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderGraphicFormat"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderGraphicPlacement":{"additional_properties":false,"fields":{"anchor_col":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"anchor_row":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"columns":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"ordinal":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"placement_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"viewport_col":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"y_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"z":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"RenderGraphics":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderGraphicsDelta":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderRow":{"additional_properties":false,"fields":{"row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"runs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRun"},"kind":"array"}}},"kind":"object"},"RenderRun":{"additional_properties":false,"fields":{"attrs":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"underline":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderUnderline"}},"width_hint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderUnderline":{"kind":"enum","values":["single","double","curly","dotted","dashed"]},"ReportAgentResult":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ResizeSurfaceResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ResolveTerminalResult":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"TerminalExit"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ResourceSelectors":{"additional_properties":false,"fields":{"agent":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"browser":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"frontend_projection":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"machine":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"notification":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pairing_request":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"sidebar_view":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"split":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"stream":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"tab":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"RunResult":{"additional_properties":false,"fields":{"already_exited":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"boolean"}},"exit":{"nullable":true,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalExit"}},"lifecycle":{"nullable":false,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Screen":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"active_pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Pane"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"SetCellPixelsResult":{"additional_properties":false,"fields":{"failures":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelFailure"},"kind":"array"}},"resizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelResize"},"kind":"array"}}},"kind":"object"},"ShutdownDaemonResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"SidebarPluginResult":{"additional_properties":false,"fields":{"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Size":{"additional_properties":false,"fields":{"cols":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"SplitDirection":{"kind":"enum","values":["right","down"]},"SurfaceResult":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tab":{"additional_properties":false,"fields":{"browser_error":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"browser_frames_stalled":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"browser_source":{"nullable":true,"presence":"required","type":{"kind":"enum","values":["external","launched"]}},"browser_status":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"enum","values":["starting","live","failed"]}},"dead":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["pty","browser"]}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"notification":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"NotificationMarker"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"size":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Size"}},"supports_clear_history_key_fallback":{"capability":"clear-history-key-v1","nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_resource_id":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":true,"presence":"optional","since":10,"type":{"kind":"scalar","name":"string"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalColors":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"TerminalEventsResult":{"additional_properties":false,"fields":{"events":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRegistryEvent"},"kind":"array"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"TerminalExit":{"additional_properties":false,"fields":{"exited_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalExitOutcome"}}},"kind":"object"},"TerminalExitOutcome":{"kind":"tagged_union","tag":"kind","variants":{"exit":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"exit"}}},"kind":"object"},"signal":{"additional_properties":false,"fields":{"core_dumped":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"signal"}},"signal":{"constraints":[{"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"unknown":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"unknown"}},"reason":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"}}},"TerminalKey":{"kind":"enum","values":["unidentified","backquote","backslash","bracket-left","bracket-right","comma","digit0","digit1","digit2","digit3","digit4","digit5","digit6","digit7","digit8","digit9","equal","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","minus","period","quote","semicolon","slash","backspace","enter","space","tab","delete","end","home","insert","page-down","page-up","arrow-down","arrow-left","arrow-right","arrow-up","numpad0","numpad1","numpad2","numpad3","numpad4","numpad5","numpad6","numpad7","numpad8","numpad9","numpad-add","numpad-backspace","numpad-comma","numpad-decimal","numpad-divide","numpad-enter","numpad-equal","numpad-multiply","numpad-subtract","numpad-up","numpad-down","numpad-right","numpad-left","numpad-begin","numpad-home","numpad-end","numpad-insert","numpad-delete","numpad-page-up","numpad-page-down","escape","f1","f2","f3","f4","f5","f6","f7","f8","f9","f10","f11","f12","f13","f14","f15","f16","f17","f18","f19","f20"]},"TerminalKeyAction":{"kind":"enum","values":["press","release","repeat"]},"TerminalKeyInput":{"additional_properties":false,"constraints":["consumed_mods must be a subset of mods.","unshifted_codepoint, shifted_codepoint, and base_layout_codepoint contain exactly one Unicode scalar when present.","utf8 contains no control characters.","macos_option_as_alt may be false only when Alt is active and consumed."],"fields":{"action":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"TerminalKeyAction"}},"base_layout_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"composing":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"consumed_mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"key":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalKey"}},"macos_option_as_alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"shifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"unshifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"utf8":{"constraints":[{"max_length":4096}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalLifecycle":{"kind":"enum","values":["launching","adopting","running","exited","tombstoned"]},"TerminalModifiers":{"additional_properties":false,"fields":{"alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"caps_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"control":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"num_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"shift":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"super":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"TerminalPlacement":{"additional_properties":false,"fields":{"already_exited":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"boolean"}},"exit":{"nullable":true,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalExit"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"TerminalRecord":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"TerminalExit"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalRegistryEvent":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"result":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tree":{"additional_properties":false,"fields":{"generation":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"pane_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"registry_id":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"workspace_revision":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"workspaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Workspace"},"kind":"array"}}},"kind":"object"},"ViewAttachmentOutcome":{"kind":"enum","values":["applied","passive","superseded"]},"VtStateResult":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"constraints":[{"encoding":"standard base64"}],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"WaitForResult":{"additional_properties":false,"fields":{"elapsed_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"matched":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Workspace":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"key":{"capability":"workspace-registry-v1","constraints":[{"format":"lowercase canonical UUID"}],"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screens":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Screen"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"WorkspaceMutationResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ZoomPaneResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"zoomed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"}}}') +SCHEMA = json.loads('{"$schema":"./sdk-schema.schema.json","commands":{"apply-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["layout must contain at least one leaf or stack member.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ApplyLayoutResult"},"since":6,"stream":null},"attach-surface":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows must be supplied together.","Browser surfaces reject mode:render."],"fields":{"cols":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"mode":{"default":"bytes","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["bytes","render"]}},"rows":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["browser-state","colors-changed","detached","frame","notification","output","overflow","render-delta","render-state","resized","scroll-changed","vt-state"],"kind":"attach","mode_field":"mode","modes":{"browser":["browser-state","frame","notification","scroll-changed","overflow","detached"],"bytes":["vt-state","output","resized","colors-changed","scroll-changed","notification","overflow","detached"],"render":["render-state","render-delta","scroll-changed","overflow","detached"]},"ordering":"Initial state precedes the command response. Later surface events preserve order. A resized replay replaces the previous byte mirror; overflow ends that surface stream.","terminal_event":"detached"}},"browser-activate":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-back":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-forward":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-frame-presented":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Acknowledges the exact rendered browser frame for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-insert-text":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up"]}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key-press":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","One request preserves the atomic press sequence through the bounded input queue."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-mouse":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; coordinates are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-mouse-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; coordinates are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-navigate":{"authority":"frontend","capability":null,"constraints":["Queue acknowledgement only; observe browser-state for outcome.","Navigation is latest-wins."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-reload":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; values are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; values are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"clear-history":{"authority":"control","capability":"clear-history-v1","constraints":["PTY surfaces only.","Failed responses classify error_delivery as known-not-delivered or ambiguous."],"request":{"additional_properties":false,"fields":{"fallback_key":{"capability":"clear-history-key-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"TerminalKeyInput"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"clear-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"close-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the close."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"close-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"CloseTerminalResult"},"since":9,"stream":null},"close-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"copy":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"CopyResult"},"since":6,"stream":null},"create-surface-with-receipt":{"authority":"control","capability":"creation-receipts-v1","constraints":["Repeating one origin and receipt with identical fields returns the original creation result.","A new idempotency_key is valid only when durable creation resolution instructs retry_new_idempotency_key."],"request":{"additional_properties":false,"constraints":["operation is one of new-tab, run-command, new-browser-tab, new-workspace, new-screen, new-pane, new-pane-right, split-right, or split-down.","Each operation admits only its documented selector and option fields.","idempotency_key names one execution attempt and defaults to receipt.","cols and rows must be supplied together."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"idempotency_key":{"capability":"creation-attempt-keys-v1","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"operation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"receipt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"selector_fallbacks":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"ResourceSelectors"},"kind":"array","max_items":7}},"selectors":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ResourceSelectors"}},"url":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"width":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"JsonValue"},"since":10,"stream":null},"create-terminal":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; when both are supplied they must identify the same workspace.","argv and command are mutually exclusive and must be nonempty when supplied.","cols and rows must be supplied together.","origin and mutation_id are either both present or both absent.","terminal_id may be supplied only when origin and mutation_id are both present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"terminal_id":{"constraints":[{"format":"32-character lowercase UUIDv4 hex without dashes","pattern":"^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$"}],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalPlacement"},"since":7,"stream":null},"create-workspace":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","At most 4096 live workspaces may exist; tombstoned keys cannot be reused."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"constraints":[{"format":"lowercase canonical UUID"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":7,"stream":null},"detach-attached-view":{"authority":"frontend","capability":"view-attachment-detach-v1","constraints":["The command closes only the named view stream and releases its size contribution.","A retired lease returns outcome:superseded."],"request":{"additional_properties":false,"fields":{"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewOutcomeResult"},"since":10,"stream":null},"detach-client":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"export-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ExportLayoutResult"},"since":6,"stream":null},"focus-direction":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"FocusDirectionResult"},"since":6,"stream":null},"focus-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"get-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["Provider endpoints and targets are disclosed only over a trusted local transport; bearer credentials are accepted only during registration and are never returned.","Automation must select a target by stable tab id instead of treating CDP discovery as topology authority."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderSnapshot"},"since":10,"stream":null},"get-cell-pixels":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"GetCellPixelsResult"},"since":6,"stream":null},"get-frontend-projection":{"authority":"control","capability":null,"constraints":["Each identifier is nonempty, contains no control character, and is at most 128 bytes."],"request":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"identify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"IdentifyResult"},"since":5,"stream":null},"ids":{"authority":"control","capability":null,"constraints":["Short ids are snapshot-local labels; command parameters accept numeric ids only."],"request":{"additional_properties":false,"fields":{"kind":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}}},"kind":"object"},"result":{"kind":"ref","name":"IdsResult"},"since":6,"stream":null},"journal-frontend-event":{"authority":"control","capability":"frontend-journal-v1","constraints":["The server derives producer identity from the authenticated control client."],"request":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"FrontendJournalEvent"}}},"kind":"object"},"result":{"additional_properties":false,"fields":{"committed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":10,"stream":null},"list-agents":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"state":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"AgentState"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ListAgentsResult"},"since":6,"stream":null},"list-clients":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"items":{"kind":"ref","name":"ClientInfo"},"kind":"array"},"since":6,"stream":null},"list-terminals":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"ListTerminalsResult"},"since":9,"stream":null},"list-workspaces":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"Tree"},"since":5,"stream":null},"mark-workspaces-provider-managed":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Authority must match the value provisioned before this mux generation accepted control clients."],"request":{"additional_properties":false,"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"mint-terminal-renderer":{"authority":"frontend","capability":null,"constraints":["Only terminal-host-backed PTYs can mint one-use renderer credentials."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":9,"stream":null},"mint-terminal-renderer-by-terminal":{"authority":"frontend","capability":null,"constraints":["The terminal resource ID is resolved atomically to the live terminal-host-backed PTY before minting a one-use renderer credential."],"request":{"additional_properties":false,"fields":{"terminal":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":11,"stream":null},"move-tab":{"authority":"control","capability":null,"constraints":["An out-of-range index clamps to the destination end."],"request":{"additional_properties":false,"fields":{"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"move-terminal":{"authority":"control","capability":null,"constraints":["A move to the current workspace still commits a terminal revision with changed:false."],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"MoveTerminalResult"},"since":9,"stream":null},"move-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"new-browser-tab":{"authority":"control","capability":null,"constraints":["Bootstrap and navigation failures are asynchronous browser-state outcomes."],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-pane-right":{"authority":"control","capability":"viewport-splits-v1","constraints":[],"request":{"additional_properties":false,"constraints":["Omitted width defaults to two thirds.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"notify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"level":{"default":"info","nullable":true,"presence":"optional","type":{"kind":"ref","name":"NotificationLevel"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"title":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"NotifyResult"},"since":6,"stream":null},"pairing-response":{"authority":"local-admin","capability":null,"constraints":["The request id must identify a live, unexpired pairing challenge."],"request":{"additional_properties":false,"fields":{"approve":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"pane-neighbor":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"PaneNeighborResult"},"since":6,"stream":null},"ping":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"PingResult"},"since":6,"stream":null},"process-info":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProcessInfoResult"},"since":6,"stream":null},"put-frontend-projection":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","Serialized projection must be at most 1048576 bytes."],"fields":{"expected_generation":{"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_projection_revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"read-screen":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScreenResult"},"since":5,"stream":null},"read-scrollback":{"authority":"control","capability":null,"constraints":["PTY surfaces only; row indexes are snapshot-relative and not durable."],"request":{"additional_properties":false,"fields":{"count":{"constraints":[{"maximum":65535,"minimum":0}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScrollbackResult"},"since":7,"stream":null},"register-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["The lease is scoped to the trusted local control connection and is released on disconnect.","The endpoint must be an explicit loopback ws URL with no credentials or fragment.","Bearer authentication is optional and sends the token only in the CDP WebSocket upgrade Authorization header.","Each registration replaces that connection\'s complete target set; target ids are never journaled."],"request":{"additional_properties":false,"fields":{"authentication":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"BrowserProviderAuthentication"}},"bearer_token":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"provider_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"targets":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"BrowserProviderTarget"},"kind":"array"}}},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderSnapshot"},"since":10,"stream":null},"release-attached-view-size":{"authority":"frontend","capability":"view-attachment-lease-v1","constraints":["The attach stream remains live for cached rendering."],"request":{"additional_properties":false,"fields":{"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewOutcomeResult"},"since":10,"stream":null},"release-surface-size":{"authority":"control","capability":null,"constraints":["An absent lease is a successful no-op."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"reload-config":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"additional_properties":false,"fields":{"path":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"reloaded":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":6,"stream":null},"rename-pane":{"authority":"control","capability":null,"constraints":["An empty name clears the pane name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the rename."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"rename-screen":{"authority":"control","capability":null,"constraints":["An empty name clears the screen name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-surface":{"authority":"control","capability":null,"constraints":["An empty name clears the surface name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"report-agent":{"authority":"control","capability":null,"constraints":["A stored hook report outranks later socket reports until another hook report or surface close."],"request":{"additional_properties":false,"fields":{"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReportAgentResult"},"since":6,"stream":null},"resize-attached-view":{"authority":"frontend","capability":"view-attachment-lease-v1","constraints":["The lease must belong to this connection and surface.","A retired lease returns outcome:superseded without changing replacement views."],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewResizeResult"},"since":10,"stream":null},"resize-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ResizeSurfaceResult"},"since":5,"stream":null},"resolve-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"ResolveTerminalResult"},"since":9,"stream":null},"run":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of argv and command must be supplied.","pane and new_workspace:true are mutually exclusive.","key is valid only with new_workspace:true.","cols and rows affect sizing only when both are present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"key":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"new_workspace":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"RunResult"},"since":6,"stream":null},"scroll-surface":{"authority":"control","capability":null,"constraints":["PTY surfaces only; negative values scroll up."],"request":{"additional_properties":false,"fields":{"delta":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both are present, UTF-8 text bytes precede decoded bytes."],"fields":{"bytes":{"constraints":[{"encoding":"standard base64"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"paste":{"default":false,"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send-key":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"keys":{"constraints":[{"syntax":"lowercase modifier+key chords"}],"nullable":false,"presence":"required","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-cell-pixels":{"authority":"frontend","capability":null,"constraints":["Accepted browser resizes complete asynchronously."],"request":{"additional_properties":false,"fields":{"height_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"width_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SetCellPixelsResult"},"since":6,"stream":null},"set-client-info":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"capabilities":{"constraints":["Advertises additive client capabilities for this connection."],"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"kind":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-client-sizing":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["exclusive:true requires client and enabled:true.","Omitting client is valid only with enabled:true and restores all clients for the surface."],"fields":{"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"enabled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"exclusive":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"set-default-colors":{"authority":"control","capability":null,"constraints":["Color strings are exactly #rrggbb.","With complete:true, absent optional values reset to built-in defaults."],"request":{"additional_properties":false,"fields":{"bg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"complete":{"default":false,"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-split-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":8,"stream":null},"set-viewport-pane-width":{"authority":"control","capability":"viewport-column-resize-v1","constraints":["width must be finite."],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"set-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"title":{"constraints":["C0 controls are sanitized before OSC output."],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"shutdown":{"authority":"local-admin","capability":"server-shutdown-v1","constraints":["The command is accepted only on a trusted Unix-classified transport.","Success means every tracked pane process terminated before the bounded shutdown deadline."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"shutdown-daemon":{"authority":"local-admin","capability":null,"constraints":["pid and generation must match the latest identify result.","force bypasses native-browser ownership only; the identity fence and trusted-local authority still apply.","Clients must require daemon-handoff-force-v1 before sending force:true.","The daemon exits only after the success response is queued."],"request":{"additional_properties":false,"fields":{"force":{"capability":"daemon-handoff-force-v1","default":false,"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"ShutdownDaemonResult"},"since":9,"stream":null},"sidebar-plugin":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"relaunch":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SidebarPluginResult"},"since":6,"stream":null},"split":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"subscribe":{"authority":"frontend","capability":null,"constraints":["subscribe sends no initial tree snapshot.","surface filtering occurs before the bounded mailbox."],"request":{"additional_properties":false,"fields":{"surface":{"capability":"surface-subscribe-filter","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"Id"}},"tree_events":{"default":"coarse","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["coarse","deltas"]}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["agent-changed","bell","client-attached","client-changed","client-detached","client-list-invalidated","config-reload-requested","empty","frontend-projection-changed","layout-changed","notification","overflow","pairing-requested","pairing-resolved","pane-added","pane-closed","screen-added","screen-closed","screen-renamed","scroll-changed","status","surface-exited","surface-output","surface-resize-failed","surface-resized","tab-added","tab-closed","tab-renamed","terminal-registry-changed","title-changed","tree-changed","window-title-requested","workspace-added","workspace-closed","workspace-moved","workspace-renamed"],"kind":"subscribe","mode_field":"tree_events","modes":{"coarse":["tree-changed"],"deltas":["workspace-added","workspace-closed","workspace-renamed","workspace-moved","screen-added","screen-closed","screen-renamed","pane-added","pane-closed","tab-added","tab-closed","tab-renamed","tree-changed"]},"ordering":"Response and event objects may interleave. Events preserve enqueue order per subscription. Delta workspace revisions are serialized in durable commit order; overflow ends the stream and requires resubscribe plus snapshot.","terminal_event":null}},"swap-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of dir and target must be supplied."],"fields":{"dir":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"target":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"terminal-events":{"authority":"control","capability":null,"constraints":["Consumers apply only contiguous revisions for one registry_id and generation."],"request":{"additional_properties":false,"fields":{"after_revision":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalEventsResult"},"since":9,"stream":null},"undo-layout":{"authority":"control","capability":"layout-undo-v1","constraints":["Clients must reject incomplete or contradictory result variants."],"request":{"additional_properties":false,"constraints":["confirm_close requires the exact preview revision."],"fields":{"confirm_close":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"LayoutUndoResult"},"since":9,"stream":null},"unregister-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["Only the calling connection\'s provider lease is removed."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderUnregisterResult"},"since":10,"stream":null},"vt-state":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"VtStateResult"},"since":5,"stream":null},"wait-for":{"authority":"control","capability":null,"constraints":["Blocks subsequent requests on this connection; SDKs should use a dedicated connection."],"request":{"additional_properties":false,"fields":{"pattern":{"constraints":[{"syntax":"Rust regex"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"timeout_ms":{"description":"Zero performs one immediate check.","nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"WaitForResult"},"since":6,"stream":null},"zoom-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"mode":{"default":"toggle","nullable":true,"presence":"optional","type":{"kind":"enum","values":["toggle","on","off"]}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ZoomPaneResult"},"since":6,"stream":null}},"events":{"agent-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"agent-changed"}},"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":11,"streams":["subscribe"]},"bell":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"bell"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"browser-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"browser-state"}},"frame":{"description":"The initial browser-state includes the latest frame when one exists; later state updates omit it.","nullable":true,"presence":"optional","type":{"kind":"ref","name":"BrowserFrame"}},"frames_stalled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"status":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["starting","live","failed"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"client-attached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-attached"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["unix","ws"]}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-changed"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-detached"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-list-invalidated":{"capability":null,"emission":"serialized-never-emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-list-invalidated"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"colors-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"CursorStyle"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"colors-changed"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"palette":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"surface":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"config-reload-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"config-reload-requested"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"detached"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte","attach-render","attach-browser"]},"empty":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"empty"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"frame":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frame"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"frontend-projection-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frontend-projection-changed"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"graphics-status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["kitty-image-budget-worker-start-failed carries error.","kitty-image-budget-update-failed carries retry_exhausted and summary.","cell-pixel-update-retries-exhausted carries attempts, remaining, cell_width, and cell_height."],"fields":{"attempts":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_height":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"graphics-status"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["kitty-image-budget-worker-start-failed","kitty-image-budget-update-failed","cell-pixel-update-retries-exhausted"]}},"remaining":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"retry_exhausted":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"summary":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":10,"streams":["subscribe"]},"layout-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"layout-changed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"notification":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"notification"}},"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-browser"]},"output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"overflow":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["scope and surface are either both present for attach overflow or both absent for subscribe overflow."],"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"overflow"}},"scope":{"nullable":false,"presence":"optional","type":{"kind":"literal","value":"surface"}},"surface":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"pairing-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-requested"}},"expires_in":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"peer":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pairing-resolved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-resolved"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pane-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"pane-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"render-delta":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["size is present if and only if the surface resized; every resize has full:true."],"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-delta"}},"full":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphicsDelta"}},"history_epoch":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"render-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-state"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphics"}},"history_epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["At least one of replay and data is present; replay is canonical from protocol 7."],"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"description":"Protocol 6 compatibility field.","nullable":false,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resized"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"replay":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Base64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"screen-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-renamed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"scroll-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"at_bottom":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"scroll-changed"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"status"}},"message":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-exited":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-exited"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-resize-failed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resize-failed"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"surface-resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resized"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tab-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-renamed"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"terminal-registry-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-registry-changed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"refetch":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-events-or-list-terminals"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"title-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"title-changed"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tree-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tree-changed"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"vt-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":6,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"vt-state"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"window-title-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"window-title-requested"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"workspace-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-added"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-closed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-moved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-moved"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-renamed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]}},"ir_sha256":"4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740","profiles":{"control":{"description":"Base authenticated session-control commands available to ordinary SDK clients.","inherits":[]},"frontend":{"description":"Rendering, input, presentation, subscribe, and attach commands.","inherits":["control"]},"local-admin":{"description":"Trusted local administration commands.","inherits":["control"],"transport":"Unix-classified transport, including direct Unix and the current stdio relay"},"provider-authority":{"description":"Provider-owned workspace mutation commands.","inherits":["control"],"requires_authority":true}},"protocol":{"id_type":"uint64","javascript_id_policy":"All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.","name":"cmux-tui-mux","version":12},"schema_version":2,"types":{"AgentRecord":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"AgentReportSource":{"kind":"enum","values":["socket","hook"]},"AgentSource":{"kind":"enum","values":["detected","socket","hook"]},"AgentState":{"kind":"enum","values":["working","blocked","idle","done","unknown"]},"AppliedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ApplyLayoutResult":{"additional_properties":false,"fields":{"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AppliedPane"},"kind":"array"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"AttachedViewOutcomeResult":{"additional_properties":false,"fields":{"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ViewAttachmentOutcome"}}},"kind":"object"},"AttachedViewResizeResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ViewAttachmentOutcome"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"Base64":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"BrowserFrame":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"BrowserProviderAuthentication":{"kind":"enum","values":["none","bearer"]},"BrowserProviderSnapshot":{"additional_properties":false,"constraints":["available is true exactly when provider_id, endpoint, authentication, and clients are present.","Provider bearer tokens are accepted only during registration and are never returned."],"fields":{"authentication":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"BrowserProviderAuthentication"}},"available":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"clients":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"endpoint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"provider_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"targets":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"BrowserProviderTarget"},"kind":"array"}}},"kind":"object"},"BrowserProviderTarget":{"additional_properties":false,"fields":{"tab_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"target_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"BrowserProviderUnregisterResult":{"additional_properties":false,"fields":{"removed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"CellPixelFailure":{"additional_properties":false,"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelResize":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"reservation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelSurface":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"ClientInfo":{"additional_properties":false,"fields":{"attached":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"connected_seconds":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"self":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"sizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ClientSize"},"kind":"array"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ClientTransport"}}},"kind":"object"},"ClientSize":{"additional_properties":false,"fields":{"cols":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"size_participating":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ClientTransport":{"kind":"enum","values":["local","unix","ws"]},"CloseTerminalResult":{"additional_properties":false,"fields":{"already_closed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"closed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ColorHex":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"CopyResult":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"CursorStyle":{"kind":"enum","values":["block","underline","bar"]},"DeadPane":{"additional_properties":false,"fields":{"dead":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"DeclarativeLayout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"command":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"EmptyResult":{"additional_properties":false,"fields":{},"kind":"object"},"ExportLayoutResult":{"additional_properties":false,"fields":{"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ExportedPane"},"kind":"array"}}},"kind":"object"},"ExportedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}}},"kind":"object"},"FocusDirectionResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"FrontendFocusTarget":{"kind":"enum","values":["pane","machine_rail","workspace_rail","tabs_rail","projection_rail"]},"FrontendJournalEvent":{"kind":"tagged_union","tag":"kind","variants":{"focus":{"additional_properties":false,"fields":{"content_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"focus"}},"pane_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"screen_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"tab_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"target":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"FrontendFocusTarget"}},"workspace_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"resize":{"additional_properties":false,"fields":{"cell_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resize"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"viewport":{"additional_properties":false,"fields":{"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"viewport"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"settled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"target":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"}}},"FrontendProjection":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"replayed":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"GetCellPixelsResult":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelSurface"},"kind":"array"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"Id":{"kind":"alias","target":{"kind":"scalar","name":"uint64"}},"IdMapping":{"additional_properties":false,"fields":{"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"IdentifyResult":{"additional_properties":false,"fields":{"app":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"cmux-tui"}},"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"capabilities":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"daemon_handoff":{"nullable":false,"presence":"required","since":9,"type":{"kind":"literal","value":1}},"generation":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"lifecycle_ready":{"default":true,"nullable":false,"presence":"optional","since":12,"type":{"kind":"scalar","name":"boolean"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"registry_id":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"session":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"shutdown_cleanup":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"ShutdownCleanupStatus"}},"terminal_revision":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"uint64"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_revision":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"IdsResult":{"additional_properties":false,"fields":{"ids":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"IdMapping"},"kind":"array"}}},"kind":"object"},"JsonValue":{"kind":"opaque_json","reason":"The wire field intentionally carries a frontend-authored or runtime-authored arbitrary JSON document."},"KittyGraphicsState":{"additional_properties":false,"fields":{"alternate_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"alternate_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"inflight_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"placements":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"primary_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"primary_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"replay_cursor_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"KittyImageAlias":{"additional_properties":false,"fields":{"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_number":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"Layout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"maximum":0.95,"minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"description":"Stable for the lifetime of this split node.","nullable":false,"presence":"optional","since":8,"type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"LayoutUndoConfirmationRequired":{"additional_properties":false,"fields":{"closes_panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"confirmation_required":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":false}}},"kind":"object"},"LayoutUndoResult":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LayoutUndoUndone"},{"kind":"ref","name":"LayoutUndoConfirmationRequired"}]},"LayoutUndoUndone":{"additional_properties":false,"fields":{"confirmation_required":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"literal","value":false}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"ListAgentsResult":{"additional_properties":false,"fields":{"agents":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AgentRecord"},"kind":"array"}}},"kind":"object"},"ListTerminalsResult":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"terminals":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRecord"},"kind":"array"}}},"kind":"object"},"LivePane":{"additional_properties":false,"fields":{"active_tab":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"focused_at":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"tabs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Tab"},"kind":"array"}}},"kind":"object"},"MintTerminalRendererResult":{"additional_properties":false,"fields":{"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"incarnation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"protocol_version":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"uint16"}},"rights":{"constraints":[{"current_value":7}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"token":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"MoveTerminalResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"NotificationLevel":{"kind":"enum","values":["info","warning","error"]},"NotificationMarker":{"additional_properties":false,"fields":{"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"unread":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"NotifyResult":{"additional_properties":false,"fields":{"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Pane":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LivePane"},{"kind":"ref","name":"DeadPane"}]},"PaneDirection":{"kind":"enum","values":["left","right","up","down"]},"PaneNeighborResult":{"additional_properties":false,"fields":{"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"PingResult":{"additional_properties":false,"fields":{"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ok":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ProcessInfoResult":{"additional_properties":false,"fields":{"command":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"cwd":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"ProviderWorkspaceMutationResult":{"additional_properties":false,"fields":{"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ReadScreenResult":{"additional_properties":false,"fields":{"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ReadScrollbackResult":{"additional_properties":false,"fields":{"epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"total":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderCursor":{"additional_properties":false,"fields":{"blink":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"color":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"style":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"CursorStyle"}},"visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderGraphicFormat":{"kind":"enum","values":["rgb","rgba"]},"RenderGraphicImage":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"format":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderGraphicFormat"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderGraphicPlacement":{"additional_properties":false,"fields":{"anchor_col":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"anchor_row":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"columns":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"ordinal":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"placement_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"viewport_col":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"y_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"z":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"RenderGraphics":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderGraphicsDelta":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderRow":{"additional_properties":false,"fields":{"row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"runs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRun"},"kind":"array"}}},"kind":"object"},"RenderRun":{"additional_properties":false,"fields":{"attrs":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"underline":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderUnderline"}},"width_hint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderUnderline":{"kind":"enum","values":["single","double","curly","dotted","dashed"]},"ReportAgentResult":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ResizeSurfaceResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ResolveTerminalResult":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"TerminalExit"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ResourceSelectors":{"additional_properties":false,"fields":{"agent":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"browser":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"frontend_projection":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"machine":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"notification":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pairing_request":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"sidebar_view":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"split":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"stream":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"tab":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"RunResult":{"additional_properties":false,"fields":{"already_exited":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"boolean"}},"exit":{"nullable":true,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalExit"}},"lifecycle":{"nullable":false,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Screen":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"active_pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Pane"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"SetCellPixelsResult":{"additional_properties":false,"fields":{"failures":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelFailure"},"kind":"array"}},"resizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelResize"},"kind":"array"}}},"kind":"object"},"ShutdownCleanupStatus":{"additional_properties":false,"fields":{"degraded":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"pending":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"retrying":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"ShutdownDaemonResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"SidebarPluginResult":{"additional_properties":false,"fields":{"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Size":{"additional_properties":false,"fields":{"cols":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"SplitDirection":{"kind":"enum","values":["right","down"]},"SurfaceResult":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tab":{"additional_properties":false,"fields":{"browser_error":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"browser_frames_stalled":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"browser_source":{"nullable":true,"presence":"required","type":{"kind":"enum","values":["external","launched"]}},"browser_status":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"enum","values":["starting","live","failed"]}},"dead":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["pty","browser"]}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"notification":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"NotificationMarker"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"size":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Size"}},"supports_clear_history_key_fallback":{"capability":"clear-history-key-v1","nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_resource_id":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":true,"presence":"optional","since":10,"type":{"kind":"scalar","name":"string"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalColors":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"TerminalEventsResult":{"additional_properties":false,"fields":{"events":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRegistryEvent"},"kind":"array"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"TerminalExit":{"additional_properties":false,"fields":{"exited_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalExitOutcome"}}},"kind":"object"},"TerminalExitOutcome":{"kind":"tagged_union","tag":"kind","variants":{"exit":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"exit"}}},"kind":"object"},"signal":{"additional_properties":false,"fields":{"core_dumped":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"signal"}},"signal":{"constraints":[{"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"unknown":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"unknown"}},"reason":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"}}},"TerminalKey":{"kind":"enum","values":["unidentified","backquote","backslash","bracket-left","bracket-right","comma","digit0","digit1","digit2","digit3","digit4","digit5","digit6","digit7","digit8","digit9","equal","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","minus","period","quote","semicolon","slash","backspace","enter","space","tab","delete","end","home","insert","page-down","page-up","arrow-down","arrow-left","arrow-right","arrow-up","numpad0","numpad1","numpad2","numpad3","numpad4","numpad5","numpad6","numpad7","numpad8","numpad9","numpad-add","numpad-backspace","numpad-comma","numpad-decimal","numpad-divide","numpad-enter","numpad-equal","numpad-multiply","numpad-subtract","numpad-up","numpad-down","numpad-right","numpad-left","numpad-begin","numpad-home","numpad-end","numpad-insert","numpad-delete","numpad-page-up","numpad-page-down","escape","f1","f2","f3","f4","f5","f6","f7","f8","f9","f10","f11","f12","f13","f14","f15","f16","f17","f18","f19","f20"]},"TerminalKeyAction":{"kind":"enum","values":["press","release","repeat"]},"TerminalKeyInput":{"additional_properties":false,"constraints":["consumed_mods must be a subset of mods.","unshifted_codepoint, shifted_codepoint, and base_layout_codepoint contain exactly one Unicode scalar when present.","utf8 contains no control characters.","macos_option_as_alt may be false only when Alt is active and consumed."],"fields":{"action":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"TerminalKeyAction"}},"base_layout_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"composing":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"consumed_mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"key":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalKey"}},"macos_option_as_alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"shifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"unshifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"utf8":{"constraints":[{"max_length":4096}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalLifecycle":{"kind":"enum","values":["launching","adopting","running","exited","tombstoned"]},"TerminalModifiers":{"additional_properties":false,"fields":{"alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"caps_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"control":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"num_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"shift":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"super":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"TerminalPlacement":{"additional_properties":false,"fields":{"already_exited":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"boolean"}},"exit":{"nullable":true,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalExit"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"TerminalRecord":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"TerminalExit"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalRegistryEvent":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"result":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tree":{"additional_properties":false,"fields":{"generation":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"pane_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"registry_id":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"workspace_revision":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"workspaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Workspace"},"kind":"array"}}},"kind":"object"},"ViewAttachmentOutcome":{"kind":"enum","values":["applied","passive","superseded"]},"VtStateResult":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"constraints":[{"encoding":"standard base64"}],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"WaitForResult":{"additional_properties":false,"fields":{"elapsed_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"matched":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Workspace":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"key":{"capability":"workspace-registry-v1","constraints":[{"format":"lowercase canonical UUID"}],"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screens":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Screen"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"WorkspaceMutationResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ZoomPaneResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"zoomed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"}}}') diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/client.py b/cmux-tui/bindings/python/cmux/raw/_generated/client.py index e9d374d32860..d0f100c24bbb 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/client.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/client.py @@ -282,6 +282,9 @@ def set_viewport_pane_width(self, pane: Id, width: float, *, transaction: Union[ def set_window_title(self, title: str) -> EmptyResult: return self._invoke_command('set-window-title', SetWindowTitleRequest(title=title)) + def shutdown(self) -> EmptyResult: + return self._invoke_command('shutdown', ShutdownRequest()) + def shutdown_daemon(self, pid: int, generation: str, *, force: Union[bool, MissingType] = MISSING) -> ShutdownDaemonResult: return self._invoke_command('shutdown-daemon', ShutdownDaemonRequest(pid=pid, generation=generation, force=force)) @@ -406,6 +409,7 @@ def zoom_pane(self, pane: Union[Id, None, MissingType] = MISSING, *, mode: Union GeneratedClientMixin.set_split_ratio.__cmux_command__ = COMMANDS['set-split-ratio'] GeneratedClientMixin.set_viewport_pane_width.__cmux_command__ = COMMANDS['set-viewport-pane-width'] GeneratedClientMixin.set_window_title.__cmux_command__ = COMMANDS['set-window-title'] +GeneratedClientMixin.shutdown.__cmux_command__ = COMMANDS['shutdown'] GeneratedClientMixin.shutdown_daemon.__cmux_command__ = COMMANDS['shutdown-daemon'] GeneratedClientMixin.sidebar_plugin.__cmux_command__ = COMMANDS['sidebar-plugin'] GeneratedClientMixin.split.__cmux_command__ = COMMANDS['split'] diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/codec.py b/cmux-tui/bindings/python/cmux/raw/_generated/codec.py index 2d3b6613d64c..51149f2eef3a 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/codec.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/codec.py @@ -81,6 +81,7 @@ class ProtocolDecodeError(ValueError): 'types/RunResult': models.RunResult, 'types/Screen': models.Screen, 'types/SetCellPixelsResult': models.SetCellPixelsResult, + 'types/ShutdownCleanupStatus': models.ShutdownCleanupStatus, 'types/ShutdownDaemonResult': models.ShutdownDaemonResult, 'types/SidebarPluginResult': models.SidebarPluginResult, 'types/Size': models.Size, @@ -195,6 +196,7 @@ class ProtocolDecodeError(ValueError): 'commands/set-split-ratio/request': models.SetSplitRatioRequest, 'commands/set-viewport-pane-width/request': models.SetViewportPaneWidthRequest, 'commands/set-window-title/request': models.SetWindowTitleRequest, + 'commands/shutdown/request': models.ShutdownRequest, 'commands/shutdown-daemon/request': models.ShutdownDaemonRequest, 'commands/sidebar-plugin/request': models.SidebarPluginRequest, 'commands/split/request': models.SplitRequest, diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py b/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py index 3f7da950fd81..68d5d3aaf99f 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py @@ -8,7 +8,7 @@ SCHEMA_VERSION = 2 MUX_PROTOCOL = 12 -IR_SHA256 = '0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529' +IR_SHA256 = '4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740' @dataclass(frozen=True) @@ -1218,6 +1218,16 @@ class EventMetadata: 'title': CommandFieldMetadata(None, None), }, ), + 'shutdown': CommandMetadata( + 'shutdown', + 'local-admin', + 9, + 'server-shutdown-v1', + ('local-admin',), + None, + { + }, + ), 'shutdown-daemon': CommandMetadata( 'shutdown-daemon', 'local-admin', diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/models.py b/cmux-tui/bindings/python/cmux/raw/_generated/models.py index 24bae4e0df9f..93a81e51792e 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/models.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/models.py @@ -516,6 +516,7 @@ class IdentifyResult: capabilities: Union[List[str], MissingType] = field(default=MISSING) ghostty_commit: Union[str, None, MissingType] = field(default=MISSING) lifecycle_ready: Union[bool, MissingType] = field(default=MISSING) + shutdown_cleanup: Union[ShutdownCleanupStatus, MissingType] = field(default=MISSING) @dataclass(frozen=True) @@ -878,6 +879,14 @@ class SetCellPixelsResult: resizes: List[CellPixelResize] +@dataclass(frozen=True) +class ShutdownCleanupStatus: + __cmux_schema_path__: ClassVar[str] = 'types/ShutdownCleanupStatus' + degraded: bool + pending: int + retrying: bool + + @dataclass(frozen=True) class ShutdownDaemonResult: __cmux_schema_path__: ClassVar[str] = 'types/ShutdownDaemonResult' @@ -1862,6 +1871,12 @@ class SetWindowTitleRequest: title: str +@dataclass(frozen=True) +class ShutdownRequest: + __cmux_schema_path__: ClassVar[str] = 'commands/shutdown/request' + pass + + @dataclass(frozen=True) class ShutdownDaemonRequest: __cmux_schema_path__: ClassVar[str] = 'commands/shutdown-daemon/request' @@ -2564,6 +2579,7 @@ class WorkspaceRenamedEvent(EventBase): 'RunResult', 'Screen', 'SetCellPixelsResult', + 'ShutdownCleanupStatus', 'ShutdownDaemonResult', 'SidebarPluginResult', 'Size', @@ -2678,6 +2694,7 @@ class WorkspaceRenamedEvent(EventBase): 'SetSplitRatioRequest', 'SetViewportPaneWidthRequest', 'SetWindowTitleRequest', + 'ShutdownRequest', 'ShutdownDaemonRequest', 'SidebarPluginRequest', 'SplitRequest', diff --git a/cmux-tui/bindings/python/tests/test_protocol.py b/cmux-tui/bindings/python/tests/test_protocol.py index 768c4cde7da7..4b20bc925baf 100644 --- a/cmux-tui/bindings/python/tests/test_protocol.py +++ b/cmux-tui/bindings/python/tests/test_protocol.py @@ -22,7 +22,7 @@ class GeneratedProtocolTests(unittest.TestCase): def test_protocol_inventory_is_exhaustive(self) -> None: self.assertEqual(MUX_PROTOCOL, 12) - self.assertEqual(len(COMMANDS), 101) + self.assertEqual(len(COMMANDS), 102) self.assertEqual(set(COMMANDS), set(SCHEMA["commands"])) self.assertEqual(set(EVENTS), set(SCHEMA["events"])) self.assertEqual(len(IR_SHA256), 64) diff --git a/cmux-tui/bindings/python/tests/test_resource_api.py b/cmux-tui/bindings/python/tests/test_resource_api.py index 924d58dcbe00..cb157d4c3103 100644 --- a/cmux-tui/bindings/python/tests/test_resource_api.py +++ b/cmux-tui/bindings/python/tests/test_resource_api.py @@ -2272,13 +2272,13 @@ def deliver_then_fail(encoded): self.assertIs(raised.exception, expected) self.assertTrue(client.closed) self.assertTrue(client._connection._wire.closed) - self.assertTrue(disconnected.wait(0.5)) started = time.monotonic() with self.assertRaises(CmuxConnectionError): session.ping() self.assertLess(time.monotonic() - started, 0.1) + self.assertTrue(disconnected.is_set()) self.assertEqual( [request["operation"] for request in observed], ["session.events"], diff --git a/cmux-tui/bindings/python/tests/test_schema_roundtrip.py b/cmux-tui/bindings/python/tests/test_schema_roundtrip.py index ba05c370931c..2958b1a0e116 100644 --- a/cmux-tui/bindings/python/tests/test_schema_roundtrip.py +++ b/cmux-tui/bindings/python/tests/test_schema_roundtrip.py @@ -86,6 +86,32 @@ def _sample( class SchemaRoundTripTests(unittest.TestCase): + def test_identify_decodes_shutdown_cleanup_health(self) -> None: + result = decode_command_result( + "identify", + { + "app": "cmux-tui", + "version": "0.1.0", + "protocol": 10, + "session": "main", + "pid": 12345, + "registry_id": "registry", + "generation": "generation", + "workspace_revision": 1, + "terminal_revision": 2, + "daemon_handoff": 1, + "shutdown_cleanup": { + "pending": 3, + "retrying": True, + "degraded": False, + }, + }, + ) + + self.assertEqual(result.shutdown_cleanup.pending, 3) + self.assertTrue(result.shutdown_cleanup.retrying) + self.assertFalse(result.shutdown_cleanup.degraded) + def test_every_command_request_encodes(self) -> None: for command, definition in SCHEMA["commands"].items(): with self.subTest(command=command): diff --git a/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json index f60936bcd6e5..48b63c7b0a78 100644 --- a/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "commands.rs", - "sha256": "e23850dfd9bac9a2cbf2bcfe587edc4c9840393584004ad77b4890c3e909844c", - "size": 62309 + "sha256": "64e09c5004ed58ccec06a012cba03ad374447becb983eead2027b661a5caba14", + "size": 62629 }, { "path": "events.rs", - "sha256": "9b00343e0c015a4a401c4e7f581b5a2f5404c3e11b531a02a3ce82777d90a329", + "sha256": "dd07802c5b50036878494ece0e0c9ad0d482f108a445fe54d0308edeb6a3a9fb", "size": 41444 }, { "path": "metadata.rs", - "sha256": "23d9345efaa612e01741aaa848a2a5ddb7af15e12b71ac4cfbef3b118765965f", - "size": 37039 + "sha256": "d0b702ec794155dd307b4f97dd318a782202821fe9a02c095639a096c9f0016a", + "size": 37272 }, { "path": "mod.rs", - "sha256": "b0be00c59d373700b8945a36af9ffe0bf374c9f878a2f61db4685c629104927b", + "sha256": "15f724f890f96929d4c78685b56b4c17c66811823bacc691f7422078316d0b0f", "size": 365 }, { "path": "types.rs", - "sha256": "357ad67df56ac7ff58b6255fffab31e7c286985d9c1c49d918048210dc1bb04b", - "size": 40955 + "sha256": "33a95922d8f9e6c80830b36dfa44b7663f96e23ae0a52470f6e1828368bf070e", + "size": 41328 } ], "format": 1, - "ir_sha256": "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529", + "ir_sha256": "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740", "language": "rust", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/rust/src/generated/commands.rs b/cmux-tui/bindings/rust/src/generated/commands.rs index 38d2cfae41e5..10088e02d5e9 100644 --- a/cmux-tui/bindings/rust/src/generated/commands.rs +++ b/cmux-tui/bindings/rust/src/generated/commands.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. // The emitter owns this layout so generation is independent of the installed rustfmt. use super::metadata::*; @@ -1155,6 +1155,14 @@ pub struct SetWindowTitleRequest { #[rustfmt::skip] pub type SetWindowTitleResult = T::EmptyResult; +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct ShutdownRequest { +} + +#[rustfmt::skip] +pub type ShutdownResult = T::EmptyResult; + #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ShutdownDaemonRequest { @@ -1748,6 +1756,10 @@ impl CmuxClient { self.execute(&SET_WINDOW_TITLE_METADATA, &request) } + pub fn shutdown(&mut self, request: ShutdownRequest) -> Result { + self.execute(&SHUTDOWN_METADATA, &request) + } + pub fn shutdown_daemon(&mut self, request: ShutdownDaemonRequest) -> Result { if request.force.is_some() { self.require_protocol_field("shutdown-daemon", 10)?; diff --git a/cmux-tui/bindings/rust/src/generated/events.rs b/cmux-tui/bindings/rust/src/generated/events.rs index 325b6f82481b..fea0a8174652 100644 --- a/cmux-tui/bindings/rust/src/generated/events.rs +++ b/cmux-tui/bindings/rust/src/generated/events.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. // The emitter owns this layout so generation is independent of the installed rustfmt. use super::metadata::*; diff --git a/cmux-tui/bindings/rust/src/generated/metadata.rs b/cmux-tui/bindings/rust/src/generated/metadata.rs index 6175cdceb904..937b6e5cc4e3 100644 --- a/cmux-tui/bindings/rust/src/generated/metadata.rs +++ b/cmux-tui/bindings/rust/src/generated/metadata.rs @@ -1,12 +1,12 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. // The emitter owns this layout so generation is independent of the installed rustfmt. use crate::{CommandMetadata, EventMetadata, ProfileMetadata, StreamMetadata}; pub const SDK_SCHEMA_VERSION: u32 = 2; pub const MUX_PROTOCOL_VERSION: u32 = 12; -pub const SDK_IR_SHA256: &str = "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529"; +pub const SDK_IR_SHA256: &str = "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740"; #[rustfmt::skip] pub const CONTROL_PROFILE: ProfileMetadata = ProfileMetadata { @@ -854,6 +854,15 @@ pub const SET_WINDOW_TITLE_METADATA: CommandMetadata = CommandMetadata { stream: None, }; +#[rustfmt::skip] +pub const SHUTDOWN_METADATA: CommandMetadata = CommandMetadata { + name: "shutdown", + since: 9, + capability: Some("server-shutdown-v1"), + authority: "local-admin", + stream: None, +}; + #[rustfmt::skip] pub const SHUTDOWN_DAEMON_METADATA: CommandMetadata = CommandMetadata { name: "shutdown-daemon", @@ -1370,6 +1379,6 @@ pub const WORKSPACE_RENAMED_EVENT_METADATA: EventMetadata = EventMetadata { #[rustfmt::skip] pub static PROFILES: &[ProfileMetadata] = &[CONTROL_PROFILE, FRONTEND_PROFILE, LOCAL_ADMIN_PROFILE, PROVIDER_AUTHORITY_PROFILE]; #[rustfmt::skip] -pub static COMMANDS: &[CommandMetadata] = &[APPLY_LAYOUT_METADATA, ATTACH_SURFACE_METADATA, BROWSER_ACTIVATE_METADATA, BROWSER_BACK_METADATA, BROWSER_FORWARD_METADATA, BROWSER_FRAME_PRESENTED_METADATA, BROWSER_INSERT_TEXT_METADATA, BROWSER_KEY_METADATA, BROWSER_KEY_PRESS_METADATA, BROWSER_MOUSE_METADATA, BROWSER_MOUSE_GUARDED_METADATA, BROWSER_NAVIGATE_METADATA, BROWSER_RELOAD_METADATA, BROWSER_WHEEL_METADATA, BROWSER_WHEEL_GUARDED_METADATA, CLEAR_HISTORY_METADATA, CLEAR_WINDOW_TITLE_METADATA, CLOSE_PANE_METADATA, CLOSE_PROVIDER_MANAGED_WORKSPACE_METADATA, CLOSE_SCREEN_METADATA, CLOSE_SURFACE_METADATA, CLOSE_TERMINAL_METADATA, CLOSE_WORKSPACE_METADATA, COPY_METADATA, CREATE_SURFACE_WITH_RECEIPT_METADATA, CREATE_TERMINAL_METADATA, CREATE_WORKSPACE_METADATA, DETACH_ATTACHED_VIEW_METADATA, DETACH_CLIENT_METADATA, EXPORT_LAYOUT_METADATA, FOCUS_DIRECTION_METADATA, FOCUS_PANE_METADATA, GET_BROWSER_PROVIDER_METADATA, GET_CELL_PIXELS_METADATA, GET_FRONTEND_PROJECTION_METADATA, IDENTIFY_METADATA, IDS_METADATA, JOURNAL_FRONTEND_EVENT_METADATA, LIST_AGENTS_METADATA, LIST_CLIENTS_METADATA, LIST_TERMINALS_METADATA, LIST_WORKSPACES_METADATA, MARK_WORKSPACES_PROVIDER_MANAGED_METADATA, MINT_TERMINAL_RENDERER_METADATA, MINT_TERMINAL_RENDERER_BY_TERMINAL_METADATA, MOVE_TAB_METADATA, MOVE_TERMINAL_METADATA, MOVE_WORKSPACE_METADATA, NEW_BROWSER_TAB_METADATA, NEW_PANE_METADATA, NEW_PANE_RIGHT_METADATA, NEW_SCREEN_METADATA, NEW_TAB_METADATA, NEW_WORKSPACE_METADATA, NOTIFY_METADATA, PAIRING_RESPONSE_METADATA, PANE_NEIGHBOR_METADATA, PING_METADATA, PROCESS_INFO_METADATA, PUT_FRONTEND_PROJECTION_METADATA, READ_SCREEN_METADATA, READ_SCROLLBACK_METADATA, REGISTER_BROWSER_PROVIDER_METADATA, RELEASE_ATTACHED_VIEW_SIZE_METADATA, RELEASE_SURFACE_SIZE_METADATA, RELOAD_CONFIG_METADATA, RENAME_PANE_METADATA, RENAME_PROVIDER_MANAGED_WORKSPACE_METADATA, RENAME_SCREEN_METADATA, RENAME_SURFACE_METADATA, RENAME_WORKSPACE_METADATA, REPORT_AGENT_METADATA, RESIZE_ATTACHED_VIEW_METADATA, RESIZE_SURFACE_METADATA, RESOLVE_TERMINAL_METADATA, RUN_METADATA, SCROLL_SURFACE_METADATA, SELECT_SCREEN_METADATA, SELECT_TAB_METADATA, SELECT_WORKSPACE_METADATA, SEND_METADATA, SEND_KEY_METADATA, SET_CELL_PIXELS_METADATA, SET_CLIENT_INFO_METADATA, SET_CLIENT_SIZING_METADATA, SET_DEFAULT_COLORS_METADATA, SET_RATIO_METADATA, SET_SPLIT_RATIO_METADATA, SET_VIEWPORT_PANE_WIDTH_METADATA, SET_WINDOW_TITLE_METADATA, SHUTDOWN_DAEMON_METADATA, SIDEBAR_PLUGIN_METADATA, SPLIT_METADATA, SUBSCRIBE_METADATA, SWAP_PANE_METADATA, TERMINAL_EVENTS_METADATA, UNDO_LAYOUT_METADATA, UNREGISTER_BROWSER_PROVIDER_METADATA, VT_STATE_METADATA, WAIT_FOR_METADATA, ZOOM_PANE_METADATA]; +pub static COMMANDS: &[CommandMetadata] = &[APPLY_LAYOUT_METADATA, ATTACH_SURFACE_METADATA, BROWSER_ACTIVATE_METADATA, BROWSER_BACK_METADATA, BROWSER_FORWARD_METADATA, BROWSER_FRAME_PRESENTED_METADATA, BROWSER_INSERT_TEXT_METADATA, BROWSER_KEY_METADATA, BROWSER_KEY_PRESS_METADATA, BROWSER_MOUSE_METADATA, BROWSER_MOUSE_GUARDED_METADATA, BROWSER_NAVIGATE_METADATA, BROWSER_RELOAD_METADATA, BROWSER_WHEEL_METADATA, BROWSER_WHEEL_GUARDED_METADATA, CLEAR_HISTORY_METADATA, CLEAR_WINDOW_TITLE_METADATA, CLOSE_PANE_METADATA, CLOSE_PROVIDER_MANAGED_WORKSPACE_METADATA, CLOSE_SCREEN_METADATA, CLOSE_SURFACE_METADATA, CLOSE_TERMINAL_METADATA, CLOSE_WORKSPACE_METADATA, COPY_METADATA, CREATE_SURFACE_WITH_RECEIPT_METADATA, CREATE_TERMINAL_METADATA, CREATE_WORKSPACE_METADATA, DETACH_ATTACHED_VIEW_METADATA, DETACH_CLIENT_METADATA, EXPORT_LAYOUT_METADATA, FOCUS_DIRECTION_METADATA, FOCUS_PANE_METADATA, GET_BROWSER_PROVIDER_METADATA, GET_CELL_PIXELS_METADATA, GET_FRONTEND_PROJECTION_METADATA, IDENTIFY_METADATA, IDS_METADATA, JOURNAL_FRONTEND_EVENT_METADATA, LIST_AGENTS_METADATA, LIST_CLIENTS_METADATA, LIST_TERMINALS_METADATA, LIST_WORKSPACES_METADATA, MARK_WORKSPACES_PROVIDER_MANAGED_METADATA, MINT_TERMINAL_RENDERER_METADATA, MINT_TERMINAL_RENDERER_BY_TERMINAL_METADATA, MOVE_TAB_METADATA, MOVE_TERMINAL_METADATA, MOVE_WORKSPACE_METADATA, NEW_BROWSER_TAB_METADATA, NEW_PANE_METADATA, NEW_PANE_RIGHT_METADATA, NEW_SCREEN_METADATA, NEW_TAB_METADATA, NEW_WORKSPACE_METADATA, NOTIFY_METADATA, PAIRING_RESPONSE_METADATA, PANE_NEIGHBOR_METADATA, PING_METADATA, PROCESS_INFO_METADATA, PUT_FRONTEND_PROJECTION_METADATA, READ_SCREEN_METADATA, READ_SCROLLBACK_METADATA, REGISTER_BROWSER_PROVIDER_METADATA, RELEASE_ATTACHED_VIEW_SIZE_METADATA, RELEASE_SURFACE_SIZE_METADATA, RELOAD_CONFIG_METADATA, RENAME_PANE_METADATA, RENAME_PROVIDER_MANAGED_WORKSPACE_METADATA, RENAME_SCREEN_METADATA, RENAME_SURFACE_METADATA, RENAME_WORKSPACE_METADATA, REPORT_AGENT_METADATA, RESIZE_ATTACHED_VIEW_METADATA, RESIZE_SURFACE_METADATA, RESOLVE_TERMINAL_METADATA, RUN_METADATA, SCROLL_SURFACE_METADATA, SELECT_SCREEN_METADATA, SELECT_TAB_METADATA, SELECT_WORKSPACE_METADATA, SEND_METADATA, SEND_KEY_METADATA, SET_CELL_PIXELS_METADATA, SET_CLIENT_INFO_METADATA, SET_CLIENT_SIZING_METADATA, SET_DEFAULT_COLORS_METADATA, SET_RATIO_METADATA, SET_SPLIT_RATIO_METADATA, SET_VIEWPORT_PANE_WIDTH_METADATA, SET_WINDOW_TITLE_METADATA, SHUTDOWN_METADATA, SHUTDOWN_DAEMON_METADATA, SIDEBAR_PLUGIN_METADATA, SPLIT_METADATA, SUBSCRIBE_METADATA, SWAP_PANE_METADATA, TERMINAL_EVENTS_METADATA, UNDO_LAYOUT_METADATA, UNREGISTER_BROWSER_PROVIDER_METADATA, VT_STATE_METADATA, WAIT_FOR_METADATA, ZOOM_PANE_METADATA]; #[rustfmt::skip] pub static EVENTS: &[EventMetadata] = &[AGENT_CHANGED_EVENT_METADATA, BELL_EVENT_METADATA, BROWSER_STATE_EVENT_METADATA, CLIENT_ATTACHED_EVENT_METADATA, CLIENT_CHANGED_EVENT_METADATA, CLIENT_DETACHED_EVENT_METADATA, CLIENT_LIST_INVALIDATED_EVENT_METADATA, COLORS_CHANGED_EVENT_METADATA, CONFIG_RELOAD_REQUESTED_EVENT_METADATA, DETACHED_EVENT_METADATA, EMPTY_EVENT_METADATA, FRAME_EVENT_METADATA, FRONTEND_PROJECTION_CHANGED_EVENT_METADATA, GRAPHICS_STATUS_EVENT_METADATA, LAYOUT_CHANGED_EVENT_METADATA, NOTIFICATION_EVENT_METADATA, OUTPUT_EVENT_METADATA, OVERFLOW_EVENT_METADATA, PAIRING_REQUESTED_EVENT_METADATA, PAIRING_RESOLVED_EVENT_METADATA, PANE_ADDED_EVENT_METADATA, PANE_CLOSED_EVENT_METADATA, RENDER_DELTA_EVENT_METADATA, RENDER_STATE_EVENT_METADATA, RESIZED_EVENT_METADATA, SCREEN_ADDED_EVENT_METADATA, SCREEN_CLOSED_EVENT_METADATA, SCREEN_RENAMED_EVENT_METADATA, SCROLL_CHANGED_EVENT_METADATA, STATUS_EVENT_METADATA, SURFACE_EXITED_EVENT_METADATA, SURFACE_OUTPUT_EVENT_METADATA, SURFACE_RESIZE_FAILED_EVENT_METADATA, SURFACE_RESIZED_EVENT_METADATA, TAB_ADDED_EVENT_METADATA, TAB_CLOSED_EVENT_METADATA, TAB_RENAMED_EVENT_METADATA, TERMINAL_REGISTRY_CHANGED_EVENT_METADATA, TITLE_CHANGED_EVENT_METADATA, TREE_CHANGED_EVENT_METADATA, VT_STATE_EVENT_METADATA, WINDOW_TITLE_REQUESTED_EVENT_METADATA, WORKSPACE_ADDED_EVENT_METADATA, WORKSPACE_CLOSED_EVENT_METADATA, WORKSPACE_MOVED_EVENT_METADATA, WORKSPACE_RENAMED_EVENT_METADATA]; diff --git a/cmux-tui/bindings/rust/src/generated/mod.rs b/cmux-tui/bindings/rust/src/generated/mod.rs index a3ba5a9a3c54..a7151f49027d 100644 --- a/cmux-tui/bindings/rust/src/generated/mod.rs +++ b/cmux-tui/bindings/rust/src/generated/mod.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. // The emitter owns this layout so generation is independent of the installed rustfmt. mod commands; diff --git a/cmux-tui/bindings/rust/src/generated/types.rs b/cmux-tui/bindings/rust/src/generated/types.rs index 74bbb47c7986..7a0d58feae5e 100644 --- a/cmux-tui/bindings/rust/src/generated/types.rs +++ b/cmux-tui/bindings/rust/src/generated/types.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. +// cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. // The emitter owns this layout so generation is independent of the installed rustfmt. use crate::{Nullable, Optional}; @@ -411,6 +411,8 @@ pub struct IdentifyResult { pub protocol: u32, pub registry_id: String, pub session: String, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub shutdown_cleanup: Option, pub terminal_revision: u64, pub version: String, pub workspace_revision: u64, @@ -869,6 +871,14 @@ pub struct SetCellPixelsResult { pub resizes: Vec, } +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ShutdownCleanupStatus { + pub degraded: bool, + pub pending: u64, + pub retrying: bool, +} + #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ShutdownDaemonResult { diff --git a/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json index c62565e01fba..7a7578acd134 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "commands.ts", - "sha256": "2a548f0bcd3005f7aae84819bb9cc948da0d7888fe021ba3ac54873f9caad5e7", - "size": 48825 + "sha256": "cd14855247deffee8b991b9ce10c036c8588b4ad4c4da006c5070b3db9365f3f", + "size": 49193 }, { "path": "events.ts", - "sha256": "70f23c4ed2cbe1df948f735ae414ca4d5525af60821b28f621e0ba6c192ff590", + "sha256": "6d7a49e205e2f52195316532b5fe595eab98a82890db961a110ca82405738460", "size": 15149 }, { "path": "index.ts", - "sha256": "e527e5563036d5366937a0266bee6ebea7ed892190a7ae0111eef796a49fdbf2", + "sha256": "57d479c9cbcc0877f1cb6b513b0668b004ffc32257cdcc48e6df4e19146c287e", "size": 272 }, { "path": "metadata.ts", - "sha256": "a4e90e9fefa36ba62e864723123546baf9ba5d672999210e68051c3a9f46deb0", - "size": 277576 + "sha256": "61c1d65b852acb6b7140dd22660640e8ec529f1eae6b1f50acc36369f0d16e90", + "size": 278962 }, { "path": "types.ts", - "sha256": "7722a98241f9521816d37249026cbae72bdc4e9685a5f929ab71b7409f747d0a", - "size": 17782 + "sha256": "fbb4041297c129a9638a7f7bd4a49887ba3fdc29f60eba4d240b3ec01e8a7193", + "size": 17937 } ], "format": 1, - "ir_sha256": "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529", + "ir_sha256": "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740", "language": "typescript", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/typescript/src/raw/generated/commands.ts b/cmux-tui/bindings/typescript/src/raw/generated/commands.ts index 0359f7f44373..912f7673b88e 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/commands.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/commands.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. */ +/* cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. */ import type * as T from "./types.js"; @@ -827,6 +827,12 @@ export interface SetWindowTitleRequest extends CmuxRequestBase { } export type SetWindowTitleResult = T.EmptyResult; +/** Protocol v9; authority: local-admin. */ +export interface ShutdownRequest extends CmuxRequestBase { + cmd: "shutdown"; +} +export type ShutdownResult = T.EmptyResult; + /** Protocol v9; authority: local-admin. */ export interface ShutdownDaemonRequest extends CmuxRequestBase { cmd: "shutdown-daemon"; @@ -1005,6 +1011,7 @@ export type CmuxRequest = | SetSplitRatioRequest | SetViewportPaneWidthRequest | SetWindowTitleRequest + | ShutdownRequest | ShutdownDaemonRequest | SidebarPluginRequest | SplitRequest @@ -1739,6 +1746,14 @@ export interface CmuxCommandDefinitionMap { capability: null; stream: null; }; + "shutdown": { + request: ShutdownRequest; + result: ShutdownResult; + authority: "local-admin"; + since: 9; + capability: "server-shutdown-v1"; + stream: null; + }; "shutdown-daemon": { request: ShutdownDaemonRequest; result: T.ShutdownDaemonResult; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/events.ts b/cmux-tui/bindings/typescript/src/raw/generated/events.ts index ff5dcd12cadf..731b3aff720d 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/events.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/events.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. */ +/* cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. */ import type * as T from "./types.js"; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/index.ts b/cmux-tui/bindings/typescript/src/raw/generated/index.ts index 94aae6efe1d5..15eee4291c17 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/index.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/index.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. */ +/* cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. */ export * from "./types.js"; export * from "./commands.js"; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts b/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts index a7fbc88ba5ef..c01a60f1ff5e 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts @@ -1,10 +1,10 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. */ +/* cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. */ export const SDK_SCHEMA_VERSION = 2 as const; export const MUX_PROTOCOL_VERSION = 12 as const; -export const SDK_IR_SHA256 = "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529" as const; +export const SDK_IR_SHA256 = "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740" as const; export const PROTOCOL = { "id_type": "uint64", "javascript_id_policy": "All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.", @@ -1060,6 +1060,17 @@ export const COMMAND_METADATA = { "stream": null, "constraints": [] }, + "shutdown": { + "authority": "local-admin", + "since": 9, + "capability": "server-shutdown-v1", + "fields": {}, + "stream": null, + "constraints": [ + "The command is accepted only on a trusted Unix-classified transport.", + "Success means every tracked pane process terminated before the bounded shutdown deadline." + ] + }, "shutdown-daemon": { "authority": "local-admin", "since": 9, @@ -2986,6 +2997,15 @@ export const TYPE_SCHEMAS: Readonly> = { "name": "string" } }, + "shutdown_cleanup": { + "nullable": false, + "presence": "optional", + "since": 10, + "type": { + "kind": "ref", + "name": "ShutdownCleanupStatus" + } + }, "terminal_revision": { "nullable": false, "presence": "required", @@ -4882,6 +4902,36 @@ export const TYPE_SCHEMAS: Readonly> = { }, "kind": "object" }, + "ShutdownCleanupStatus": { + "additional_properties": false, + "fields": { + "degraded": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "boolean" + } + }, + "pending": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "retrying": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "boolean" + } + } + }, + "kind": "object" + }, "ShutdownDaemonResult": { "additional_properties": false, "fields": { @@ -10119,6 +10169,17 @@ export const COMMAND_SCHEMAS: Readonly> = { "name": "EmptyResult" } }, + "shutdown": { + "request": { + "additional_properties": false, + "fields": {}, + "kind": "object" + }, + "result": { + "kind": "ref", + "name": "EmptyResult" + } + }, "shutdown-daemon": { "request": { "additional_properties": false, diff --git a/cmux-tui/bindings/typescript/src/raw/generated/types.ts b/cmux-tui/bindings/typescript/src/raw/generated/types.ts index 0672001a31f8..cac6c3087e28 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/types.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/types.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529. */ +/* cmux-tui mux protocol 12, IR 4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740. */ /** JSON accepted by the wire codec. bigint is serialized as an exact JSON integer. */ @@ -235,6 +235,7 @@ export type IdentifyResult = { "protocol": number; "registry_id": string; "session": string; + "shutdown_cleanup"?: ShutdownCleanupStatus; "terminal_revision": bigint; "version": string; "workspace_revision": bigint; @@ -539,6 +540,12 @@ export type SetCellPixelsResult = { "resizes": Array; }; +export type ShutdownCleanupStatus = { + "degraded": boolean; + "pending": bigint; + "retrying": boolean; +}; + export type ShutdownDaemonResult = { "accepted": true; "generation": string; diff --git a/cmux-tui/bindings/typescript/test/generated.test.ts b/cmux-tui/bindings/typescript/test/generated.test.ts index ab72361348a0..54844f8da8d1 100644 --- a/cmux-tui/bindings/typescript/test/generated.test.ts +++ b/cmux-tui/bindings/typescript/test/generated.test.ts @@ -12,7 +12,7 @@ import { test("generated protocol coverage matches the canonical v12 IR", () => { assert.equal(MUX_PROTOCOL_VERSION, 12); assert.equal(SDK_SCHEMA_VERSION, 2); - assert.equal(Object.keys(COMMAND_METADATA).length, 101); + assert.equal(Object.keys(COMMAND_METADATA).length, 102); assert.equal(Object.keys(EVENT_METADATA).length, 46); assert.equal(SDK_IR_SHA256.length, 64); assert.deepEqual(Object.keys(PROFILES).sort(), [ diff --git a/cmux-tui/bindings/zig/examples/watch.zig b/cmux-tui/bindings/zig/examples/watch.zig index 36cb15780b21..a6703b6b304b 100644 --- a/cmux-tui/bindings/zig/examples/watch.zig +++ b/cmux-tui/bindings/zig/examples/watch.zig @@ -126,7 +126,7 @@ test "package consumer imports handwritten root and generated raw module" { wheel.pointer_frame_seq, ); try std.testing.expectEqual( - @as(usize, 101), + @as(usize, 102), cmux.raw.protocol.command_count, ); try std.testing.expectEqual( diff --git a/cmux-tui/bindings/zig/src/raw.zig b/cmux-tui/bindings/zig/src/raw.zig index 98bf3539959a..9421d9db299b 100644 --- a/cmux-tui/bindings/zig/src/raw.zig +++ b/cmux-tui/bindings/zig/src/raw.zig @@ -44,7 +44,7 @@ test { _ = @import("raw/wire_presence_test.zig"); _ = @import("raw/generated/presence_test.zig"); std.testing.refAllDecls(protocol); - try std.testing.expectEqual(@as(usize, 101), protocol.command_count); + try std.testing.expectEqual(@as(usize, 102), protocol.command_count); for ([_][]const u8{ "browser-frame-presented", "browser-key-press", diff --git a/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json index ff1d2264d341..abd8ed7ff0ce 100644 --- a/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json @@ -2,17 +2,17 @@ "files": [ { "path": "presence_test.zig", - "sha256": "1525e8bd8cd98b0becc9a3c33e2ef4f7d8b85657f9de015d8584152b399fd5bf", - "size": 6955 + "sha256": "7046907df8142cde3c1c5c497533bc0a597987c6053a02396429faa9f5288b81", + "size": 7036 }, { "path": "protocol.zig", - "sha256": "3ef31b9abc42f11c86336203dcc9dea606d08ece3675f2e734122870eb9b3c21", - "size": 157881 + "sha256": "3495809ce18b487e8c6bd6d763cbb3b96f8ca08ac148da0dda48e1dc4d50a380", + "size": 158606 } ], "format": 1, - "ir_sha256": "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529", + "ir_sha256": "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740", "language": "zig", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/zig/src/raw/generated/presence_test.zig b/cmux-tui/bindings/zig/src/raw/generated/presence_test.zig index b1aaafabda08..0ab1a77315e9 100644 --- a/cmux-tui/bindings/zig/src/raw/generated/presence_test.zig +++ b/cmux-tui/bindings/zig/src/raw/generated/presence_test.zig @@ -73,6 +73,7 @@ test "every generated optional non-null field rejects explicit null" { try expectExplicitNullRejected(protocol.FrontendProjection, "replayed"); try expectExplicitNullRejected(protocol.IdentifyResult, "capabilities"); try expectExplicitNullRejected(protocol.IdentifyResult, "lifecycle_ready"); + try expectExplicitNullRejected(protocol.IdentifyResult, "shutdown_cleanup"); try expectExplicitNullRejected(protocol.LayoutSplit, "split"); try expectExplicitNullRejected(protocol.LayoutUndoUndone, "confirmation_required"); try expectExplicitNullRejected(protocol.LivePane, "focused_at"); diff --git a/cmux-tui/bindings/zig/src/raw/generated/protocol.zig b/cmux-tui/bindings/zig/src/raw/generated/protocol.zig index acfdeea24421..75e914de2a76 100644 --- a/cmux-tui/bindings/zig/src/raw/generated/protocol.zig +++ b/cmux-tui/bindings/zig/src/raw/generated/protocol.zig @@ -7,7 +7,7 @@ const client_runtime = @import("../client.zig"); pub const schema_version: u16 = 2; pub const mux_protocol: u16 = 12; -pub const ir_sha256 = "0f28922d64be59160110a6e7bf5a7656132ce163e82792c474c29c26a1bee529"; +pub const ir_sha256 = "4fb03300ecced6bd23420c62e25fed1b3741af8f2772baf4caf2eb2ff3a91740"; pub const AgentRecord = struct { session: wire.Nullable([]const u8), @@ -494,6 +494,7 @@ pub const IdentifyResult = struct { protocol: u32, registry_id: []const u8, session: []const u8, + shutdown_cleanup: ?ShutdownCleanupStatus = null, terminal_revision: u64, version: []const u8, workspace_revision: u64, @@ -501,6 +502,7 @@ pub const IdentifyResult = struct { pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ "capabilities", "lifecycle_ready", + "shutdown_cleanup", }; }; @@ -994,6 +996,12 @@ pub const SetCellPixelsResult = struct { resizes: []const CellPixelResize, }; +pub const ShutdownCleanupStatus = struct { + degraded: bool, + pending: u64, + retrying: bool, +}; + pub const ShutdownDaemonResult = struct { accepted: bool, generation: []const u8, @@ -3798,6 +3806,23 @@ pub fn setWindowTitle(client: anytype, request: SetWindowTitleRequest) !wire.Dec ); } +pub const ShutdownRequest = struct {}; + +pub const ShutdownResult = EmptyResult; + +pub fn shutdown(client: anytype, request: ShutdownRequest) !wire.Decoded(ShutdownResult) { + return client.callTyped( + ShutdownResult, + .{ + .name = "shutdown", + .authority = "local-admin", + .since = 9, + .capability = "server-shutdown-v1", + }, + request, + ); +} + pub const ShutdownDaemonRequest = struct { force: ?bool = null, generation: []const u8, @@ -4917,7 +4942,7 @@ pub const CommandDescriptor = struct { stream: ?[]const u8, }; -pub const command_count: usize = 101; +pub const command_count: usize = 102; pub const commands = [_]CommandDescriptor{ .{ .name = "apply-layout", .authority = "control", .since = 6, .capability = null, .stream = null }, .{ .name = "attach-surface", .authority = "frontend", .since = 5, .capability = null, .stream = "attach" }, @@ -5009,6 +5034,7 @@ pub const commands = [_]CommandDescriptor{ .{ .name = "set-split-ratio", .authority = "control", .since = 8, .capability = null, .stream = null }, .{ .name = "set-viewport-pane-width", .authority = "control", .since = 9, .capability = "viewport-column-resize-v1", .stream = null }, .{ .name = "set-window-title", .authority = "control", .since = 6, .capability = null, .stream = null }, + .{ .name = "shutdown", .authority = "local-admin", .since = 9, .capability = "server-shutdown-v1", .stream = null }, .{ .name = "shutdown-daemon", .authority = "local-admin", .since = 9, .capability = null, .stream = null }, .{ .name = "sidebar-plugin", .authority = "frontend", .since = 6, .capability = null, .stream = null }, .{ .name = "split", .authority = "control", .since = 5, .capability = null, .stream = null }, diff --git a/cmux-tui/crates/cmux-pty/Cargo.toml b/cmux-tui/crates/cmux-pty/Cargo.toml index 557f55fbd29f..290bb3b26411 100644 --- a/cmux-tui/crates/cmux-pty/Cargo.toml +++ b/cmux-tui/crates/cmux-pty/Cargo.toml @@ -12,5 +12,6 @@ workspace = true [dependencies] anyhow.workspace = true +cmux-tui-process.workspace = true libc.workspace = true portable-pty.workspace = true diff --git a/cmux-tui/crates/cmux-pty/src/lib.rs b/cmux-tui/crates/cmux-pty/src/lib.rs index 727d48b1f777..987894b4d431 100644 --- a/cmux-tui/crates/cmux-pty/src/lib.rs +++ b/cmux-tui/crates/cmux-pty/src/lib.rs @@ -4,7 +4,7 @@ //! descriptor-pinned working directories. Non-Unix platforms use //! portable-pty's native backend. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; #[cfg(unix)] use std::fs::File; @@ -96,6 +96,7 @@ pub struct PtyCommand { #[cfg(unix)] cwd_descriptor: Option>, environment: BTreeMap, + removed_environment: BTreeSet, clean_environment: bool, } @@ -108,6 +109,7 @@ impl PtyCommand { #[cfg(unix)] cwd_descriptor: None, environment: BTreeMap::new(), + removed_environment: BTreeSet::new(), clean_environment: false, } } @@ -135,12 +137,21 @@ impl PtyCommand { } pub fn env(&mut self, key: impl Into, value: impl Into) { - self.environment.insert(key.into(), value.into()); + let key = key.into(); + self.removed_environment.remove(&key); + self.environment.insert(key, value.into()); + } + + pub fn env_remove(&mut self, key: impl Into) { + let key = key.into(); + self.environment.remove(&key); + self.removed_environment.insert(key); } pub fn env_clear(&mut self) { self.clean_environment = true; self.environment.clear(); + self.removed_environment.clear(); } } @@ -201,6 +212,9 @@ mod platform { if let Some(cwd) = command.cwd { builder.cwd(cwd); } + for key in command.removed_environment { + builder.env_remove(key); + } for (key, value) in command.environment { builder.env(key, value); } @@ -271,6 +285,39 @@ mod tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn pty_spawn_waits_for_process_creation_barrier() { + let pair = open(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 }).unwrap(); + let barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (sender, receiver) = std::sync::mpsc::channel(); + let publisher = std::thread::spawn(move || { + sender.send(pair.spawn(PtyCommand::new("/usr/bin/true"))).unwrap(); + }); + + let early = match receiver.recv_timeout(std::time::Duration::from_millis(250)) { + Ok(result) => Some(result), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None, + Err(error) => panic!("PTY spawn channel failed: {error}"), + }; + drop(barrier); + + let spawned_while_barrier_held = early.is_some(); + let result = match early { + Some(result) => result, + None => receiver + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("PTY spawn did not resume after the process barrier was released"), + }; + let mut spawned = result.unwrap(); + assert!(spawned.child.wait().unwrap().success()); + publisher.join().unwrap(); + assert!( + !spawned_while_barrier_held, + "PTY child started while the process barrier was held" + ); + } + #[cfg(target_os = "linux")] #[test] fn close_range_seccomp_fallback_keeps_pty_spawn_working() { diff --git a/cmux-tui/crates/cmux-pty/src/macos.rs b/cmux-tui/crates/cmux-pty/src/macos.rs index b7581279dc08..94b72289fcab 100644 --- a/cmux-tui/crates/cmux-pty/src/macos.rs +++ b/cmux-tui/crates/cmux-pty/src/macos.rs @@ -37,6 +37,7 @@ impl DescriptorCleanup { } pub(crate) fn open(size: PtySize) -> anyhow::Result<(Box, Slave)> { + let _process_creation = cmux_tui_process::ProcessCreationGuard::acquire(); let master = open_pty_master()?; retry_pty_control(master.as_raw_fd(), libc::grantpt).context("failed to grant PTY slave")?; retry_pty_control(master.as_raw_fd(), libc::unlockpt).context("failed to unlock PTY slave")?; @@ -169,6 +170,7 @@ pub(crate) fn spawn( slave: &Slave, command: PtyCommand, ) -> anyhow::Result> { + let _process_creation = cmux_tui_process::ProcessCreationGuard::acquire(); let shell = resolved_shell(&command); let mut process = Command::new(&command.program); process.args(&command.args); @@ -180,6 +182,9 @@ pub(crate) fn spawn( if command.clean_environment { process.env_clear(); } + for key in &command.removed_environment { + process.env_remove(key); + } process.envs(&command.environment); process.env("SHELL", shell); let cwd_descriptor = command.cwd_descriptor; diff --git a/cmux-tui/crates/cmux-remote/Cargo.toml b/cmux-tui/crates/cmux-remote/Cargo.toml index 5c84a637c48f..95238c9d6671 100644 --- a/cmux-tui/crates/cmux-remote/Cargo.toml +++ b/cmux-tui/crates/cmux-remote/Cargo.toml @@ -22,6 +22,7 @@ base64.workspace = true bytes.workspace = true cmux-pty.workspace = true cmux-remote-protocol.workspace = true +cmux-tui-process.workspace = true cmux-tui-core.workspace = true diffy.workspace = true fs4.workspace = true @@ -60,3 +61,4 @@ rcgen = "0.14" tempfile = "3" tokio = { workspace = true, features = ["test-util"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +wait-timeout.workspace = true diff --git a/cmux-tui/crates/cmux-remote/src/admin.rs b/cmux-tui/crates/cmux-remote/src/admin.rs index 5fd20c8ee846..a0a19ac4a3d1 100644 --- a/cmux-tui/crates/cmux-remote/src/admin.rs +++ b/cmux-tui/crates/cmux-remote/src/admin.rs @@ -256,7 +256,7 @@ pub async fn call_admin( path: impl AsRef, request: &AdminRequest, ) -> Result { - let stream = UnixStream::connect(path).await?; + let stream = cmux_tui_process::tokio_net::connect_unix_stream(path).await?; verify_unix_peer_owner(&stream)?; call_admin_over_stream(stream, request).await } @@ -271,7 +271,7 @@ pub async fn call_admin_with_peer_exit( path: impl AsRef, request: &AdminRequest, ) -> Result<(AdminResponse, UnixPeerProcessExit), AdminError> { - let stream = UnixStream::connect(path).await?; + let stream = cmux_tui_process::tokio_net::connect_unix_stream(path).await?; verify_unix_peer_owner(&stream)?; let peer_pid = stream.peer_cred()?.pid().ok_or_else(|| { io::Error::new( @@ -567,7 +567,7 @@ async fn call_admin_with_expected_uid( request: &AdminRequest, expected_uid: u32, ) -> Result { - let stream = UnixStream::connect(path).await?; + let stream = cmux_tui_process::tokio_net::connect_unix_stream(path).await?; verify_unix_peer_uid(&stream, expected_uid)?; call_admin_over_stream(stream, request).await } diff --git a/cmux-tui/crates/cmux-remote/src/bridge.rs b/cmux-tui/crates/cmux-remote/src/bridge.rs index 66061a7341df..ef2ad446b30f 100644 --- a/cmux-tui/crates/cmux-remote/src/bridge.rs +++ b/cmux-tui/crates/cmux-remote/src/bridge.rs @@ -41,7 +41,7 @@ impl LocalPortForward { if maximum_connections == 0 { return Err(BridgeError::InvalidConnectionLimit); } - let listener = tokio::net::TcpListener::bind(address).await?; + let listener = cmux_tui_process::tokio_net::bind_tcp_listener(address)?; let local_addr = listener.local_addr()?; let permits = Arc::new(Semaphore::new(maximum_connections)); let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); @@ -58,7 +58,7 @@ impl LocalPortForward { }; let accepted = tokio::select! { _ = &mut shutdown_rx => break, - accepted = listener.accept() => accepted, + accepted = cmux_tui_process::tokio_net::accept_tcp_stream(&listener) => accepted, }; let Ok((socket, _)) = accepted else { break }; if configure_forward_socket(&socket).is_err() { @@ -132,7 +132,7 @@ pub async fn serve_mux_bridge( completed = connections.join_next(), if !connections.is_empty() => { let _ = completed; } - accepted = listener.accept() => { + accepted = cmux_tui_process::tokio_net::accept_unix_stream(listener) => { let Ok((socket, _)) = accepted else { break }; let multiplexer = multiplexer.clone(); connections.spawn(async move { diff --git a/cmux-tui/crates/cmux-remote/src/daemon.rs b/cmux-tui/crates/cmux-remote/src/daemon.rs index 00531821195d..27bde7d5d77e 100644 --- a/cmux-tui/crates/cmux-remote/src/daemon.rs +++ b/cmux-tui/crates/cmux-remote/src/daemon.rs @@ -1277,7 +1277,7 @@ impl Listener for LimitedTcpListener { .acquire_owned() .await .expect("direct WebSocket admission semaphore is never closed"); - match self.inner.accept().await { + match cmux_tui_process::tokio_net::accept_tcp_stream(&self.inner).await { Ok((inner, address)) => { let _ = inner.set_nodelay(true); return ( @@ -1389,8 +1389,7 @@ pub async fn serve_direct_websocket( "refusing plaintext remote WebSocket bind {address}; use TLS or explicitly allow it" ))); } - let listener = tokio::net::TcpListener::bind(address) - .await + let listener = cmux_tui_process::tokio_net::bind_tcp_listener(address) .map_err(|error| DaemonError::Protocol(format!("could not bind WebSocket: {error}")))?; let local_addr = listener.local_addr().map_err(|error| { DaemonError::Protocol(format!("could not read WebSocket address: {error}")) @@ -1697,6 +1696,82 @@ mod tests { CarrierEvidence, LinkGroup, LinkRequest, ProviderCapabilities, ProviderError, }; + #[cfg(target_os = "macos")] + fn hold_process_creation_barrier() -> (std::sync::mpsc::Sender<()>, std::thread::JoinHandle<()>) + { + let (held_sender, held_receiver) = std::sync::mpsc::channel(); + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + held_sender.send(()).unwrap(); + release_receiver.recv().unwrap(); + }); + held_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("process creation barrier was not acquired"); + (release_sender, holder) + } + + #[cfg(target_os = "macos")] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn direct_socket_bind_waits_for_process_creation_barrier() { + let directory = tempdir().unwrap(); + let auth = AuthDatabase::load_or_create(directory.path(), "guarded-bind", true).unwrap(); + let (daemon, _accepted) = RemoteDaemon::new(auth, SessionLimits::default()); + let (release, holder) = hold_process_creation_barrier(); + + let (started_sender, started_receiver) = oneshot::channel(); + let mut bind = tokio::spawn(async move { + started_sender.send(()).unwrap(); + serve_direct_websocket(daemon, "127.0.0.1:0".parse().unwrap(), 128 * 1024, false).await + }); + started_receiver.await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(250), &mut bind).await.is_err(), + "direct socket bound while a concurrent process could inherit its descriptor" + ); + release.send(()).unwrap(); + holder.join().unwrap(); + + let server = tokio::time::timeout(Duration::from_secs(5), bind) + .await + .expect("direct socket bind did not resume after the process barrier was released") + .unwrap() + .unwrap(); + server.shutdown().await.unwrap(); + } + + #[cfg(target_os = "macos")] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn direct_socket_accept_waits_for_process_creation_barrier() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let client = tokio::net::TcpStream::connect(address).await.unwrap(); + let mut listener = + LimitedTcpListener { inner: listener, permits: Arc::new(Semaphore::new(1)) }; + let (release, holder) = hold_process_creation_barrier(); + + let (started_sender, started_receiver) = oneshot::channel(); + let mut accept = tokio::spawn(async move { + started_sender.send(()).unwrap(); + listener.accept().await + }); + started_receiver.await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(250), &mut accept).await.is_err(), + "direct socket accepted while a concurrent process could inherit its descriptor" + ); + release.send(()).unwrap(); + holder.join().unwrap(); + + let (server, _) = tokio::time::timeout(Duration::from_secs(5), accept) + .await + .expect("direct socket accept did not resume after the process barrier was released") + .unwrap(); + drop(server); + drop(client); + } + struct PreludeProbeLink { reads: Arc, } diff --git a/cmux-tui/crates/cmux-remote/src/http.rs b/cmux-tui/crates/cmux-remote/src/http.rs index 21be14d67414..69855dc1c826 100644 --- a/cmux-tui/crates/cmux-remote/src/http.rs +++ b/cmux-tui/crates/cmux-remote/src/http.rs @@ -284,7 +284,7 @@ async fn serve_workspace_http_with_limits( )); } let token = load_or_create_workspace_http_token(&token_file)?; - let listener = TcpListener::bind(address).await?; + let listener = cmux_tui_process::tokio_net::bind_tcp_listener(address)?; let local_addr = listener.local_addr()?; let router = workspace_http_router(workspace, token); let (shutdown_tx, shutdown_rx) = oneshot::channel(); diff --git a/cmux-tui/crates/cmux-remote/src/identity.rs b/cmux-tui/crates/cmux-remote/src/identity.rs index 0069c5bd1bf1..1abe6b84f1a4 100644 --- a/cmux-tui/crates/cmux-remote/src/identity.rs +++ b/cmux-tui/crates/cmux-remote/src/identity.rs @@ -590,12 +590,18 @@ impl PersistenceCoordinator { } } } + #[cfg(test)] + let accepted_submission = sender.is_none() || matches!(immediate.as_ref(), Some(Ok(()))); if let Some(result) = immediate { let _ = sender.expect("immediate persistence waiter is available").send(result); } if wake_worker { self.shared.changed.notify_one(); } + #[cfg(test)] + if accepted_submission { + self.hooks.record_accepted_submission(revision); + } PersistenceWaiter { receiver } } @@ -781,6 +787,7 @@ fn take_waiters_through( struct PersistenceTestHooks { writes_started: std::sync::atomic::AtomicUsize, writes_succeeded: std::sync::atomic::AtomicUsize, + highest_accepted_submission: std::sync::atomic::AtomicU64, fail_next: std::sync::atomic::AtomicUsize, fail_next_parent_sync: std::sync::atomic::AtomicUsize, panic_next: std::sync::atomic::AtomicUsize, @@ -788,6 +795,7 @@ struct PersistenceTestHooks { blocked: StdMutex, released: Condvar, started: Notify, + submission_accepted: Notify, } #[cfg(test)] @@ -821,15 +829,16 @@ impl PersistenceTestHooks { { return Err(format!("injected persistence failure for revision {revision}")); } - if let Some(gate) = std::env::var_os("CMUX_TEST_AUTH_PERSISTENCE_GATE") { - let gate = PathBuf::from(gate); - while !gate.exists() { - std::thread::sleep(Duration::from_millis(1)); - } - } Ok(()) } + fn record_accepted_submission(&self, revision: u64) { + use std::sync::atomic::Ordering; + + self.highest_accepted_submission.fetch_max(revision, Ordering::SeqCst); + self.submission_accepted.notify_waiters(); + } + fn after_write(&self, succeeded: bool) { if succeeded { self.writes_succeeded.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -865,6 +874,18 @@ impl PersistenceTestHooks { started.await; } } + + async fn wait_for_accepted_submission(&self, revision: u64) { + use std::sync::atomic::Ordering; + + loop { + let accepted = self.submission_accepted.notified(); + if self.highest_accepted_submission.load(Ordering::SeqCst) >= revision { + return; + } + accepted.await; + } + } } #[cfg(test)] @@ -2307,8 +2328,14 @@ impl std::error::Error for IdentityError {} #[cfg(test)] mod tests { use std::collections::BTreeMap; + use std::io::{BufRead as _, BufReader, Write as _}; + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::sync::mpsc::{self, Receiver, RecvTimeoutError, TrySendError}; + use std::thread::JoinHandle; + use std::time::Instant; use cmux_remote_protocol::{Lane, SessionId}; + use wait_timeout::ChildExt; use zeroize::Zeroizing; use super::*; @@ -2317,6 +2344,13 @@ mod tests { }; use crate::link::test_support; + const AUTH_PERSISTENCE_QUEUED_MARKER: &str = "cmux-auth-persistence:revision-2-submitted:v1"; + const AUTH_PERSISTENCE_RELEASE_MARKER: &str = "cmux-auth-persistence:release-worker:v1"; + const AUTH_PERSISTENCE_DRAINED_MARKER: &str = "cmux-auth-persistence:worker-drained:v1"; + const AUTH_PERSISTENCE_CHILD_PHASE_TIMEOUT: Duration = Duration::from_secs(3); + // Successful child output is two protocol markers plus six libtest lines. + const AUTH_PERSISTENCE_CHILD_OUTPUT_LINES: usize = 8; + struct PersistenceReleaseGuard(Arc); impl PersistenceReleaseGuard { @@ -2332,6 +2366,120 @@ mod tests { } } + struct AuthPersistenceChildOwner { + child: Option, + markers: Receiver, + output_reader: Option>>, + } + + impl AuthPersistenceChildOwner { + fn new(child: Child) -> Self { + let (marker_sender, markers) = mpsc::sync_channel(AUTH_PERSISTENCE_CHILD_OUTPUT_LINES); + let mut owner = Self { child: Some(child), markers, output_reader: None }; + let stdout = owner + .child + .as_mut() + .and_then(|child| child.stdout.take()) + .expect("persistence child stdout was not piped"); + let output_reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + match marker_sender.try_send(line?) { + Ok(()) => {} + Err(TrySendError::Disconnected(_)) => break, + Err(TrySendError::Full(_)) => { + return Err(std::io::Error::other( + "persistence child output exceeded its fixed line capacity", + )); + } + } + } + Ok(()) + }); + owner.output_reader = Some(output_reader); + owner + } + + fn wait_for_marker(&self, expected: &str, phase: &str) { + let deadline = Instant::now() + AUTH_PERSISTENCE_CHILD_PHASE_TIMEOUT; + let mut observed = Vec::new(); + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + panic!("persistence child {phase} deadline expired; output: {observed:?}"); + }; + match self.markers.recv_timeout(remaining) { + Ok(line) if line == expected => return, + Ok(line) => observed.push(line), + Err(RecvTimeoutError::Timeout) => { + panic!("persistence child {phase} deadline expired; output: {observed:?}"); + } + Err(RecvTimeoutError::Disconnected) => { + panic!( + "persistence child output closed before {phase}; output: {observed:?}" + ); + } + } + } + } + + fn send_release(&mut self) { + let mut stdin = self + .child + .as_mut() + .and_then(|child| child.stdin.take()) + .expect("persistence child stdin was not piped"); + writeln!(stdin, "{AUTH_PERSISTENCE_RELEASE_MARKER}").unwrap(); + stdin.flush().unwrap(); + } + + fn wait_for_exit(&mut self) -> ExitStatus { + let status = self + .child + .as_mut() + .expect("persistence child owner was disarmed before exit") + .wait_timeout(AUTH_PERSISTENCE_CHILD_PHASE_TIMEOUT) + .expect("failed to wait for persistence child") + .unwrap_or_else(|| panic!("persistence child exit deadline expired")); + self.child.take(); + status + } + + fn finish_output(&mut self) { + self.output_reader + .take() + .expect("persistence child output reader was already joined") + .join() + .expect("persistence child output reader panicked") + .expect("failed to read persistence child output"); + } + } + + impl Drop for AuthPersistenceChildOwner { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(output_reader) = self.output_reader.take() { + let _ = output_reader.join(); + } + } + } + + fn write_auth_persistence_child_marker(marker: &str) { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + writeln!(stdout, "{marker}").unwrap(); + stdout.flush().unwrap(); + } + + fn wait_for_auth_persistence_release() { + let stdin = std::io::stdin(); + let mut release = String::new(); + let bytes = BufReader::new(stdin.lock()).read_line(&mut release).unwrap(); + assert_ne!(bytes, 0, "persistence child release channel closed without a marker"); + assert_eq!(release.trim_end(), AUTH_PERSISTENCE_RELEASE_MARKER); + } + #[tokio::test] async fn identity_is_stable_and_files_are_private() { let temp = tempfile::tempdir().unwrap(); @@ -2568,13 +2716,13 @@ mod tests { ); drop(blocked); - let deadline = std::time::Instant::now() + Duration::from_secs(1); + let deadline = Instant::now() + Duration::from_secs(1); loop { match AuthDatabase::load_or_create(temp.path(), "successor", false) { Ok(_) => break, Err(IdentityError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => { assert!( - std::time::Instant::now() < deadline, + Instant::now() < deadline, "the state lease remained held after the blocking write completed" ); std::thread::yield_now(); @@ -2629,7 +2777,7 @@ mod tests { let drop_thread = std::thread::spawn(move || drop(database)); drop(blocked); drop_thread.join().unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(1); + let deadline = Instant::now() + Duration::from_secs(1); loop { match AuthDatabase::load_or_create(temp.path(), "successor", false) { Ok(successor) => { @@ -2638,7 +2786,7 @@ mod tests { } Err(IdentityError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => { assert!( - std::time::Instant::now() < deadline, + Instant::now() < deadline, "the state lease remained held after queued persistence completed" ); std::thread::yield_now(); @@ -2856,16 +3004,16 @@ mod tests { let Some(state_dir) = std::env::var_os("CMUX_TEST_AUTH_PERSISTENCE_STATE") else { return; }; - let queued = PathBuf::from(std::env::var_os("CMUX_TEST_AUTH_PERSISTENCE_QUEUED").unwrap()); let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() .build() .unwrap(); - let database = runtime.block_on(async { + let (database, hooks) = runtime.block_on(async { let database = AuthDatabase::load_or_create(state_dir, "child", false).unwrap(); let hooks = database.persistence.hooks.clone(); - tokio::spawn({ + hooks.block(); + let first = tokio::spawn({ let database = database.clone(); async move { let _ = database.create_invitation(Duration::from_secs(60), Vec::new()).await; @@ -2874,62 +3022,59 @@ mod tests { tokio::time::timeout(Duration::from_secs(1), hooks.wait_for_started(1)) .await .expect("child persistence write did not start"); - tokio::spawn({ + let second = tokio::spawn({ let database = database.clone(); async move { let _ = database.create_invitation(Duration::from_secs(60), Vec::new()).await; } }); - tokio::time::timeout(Duration::from_secs(1), async { - loop { - if database.state.lock().await.revision == 2 { - break; + tokio::time::timeout(Duration::from_secs(1), hooks.wait_for_accepted_submission(2)) + .await + .expect("child did not submit its second authorization revision"); + first.abort(); + second.abort(); + tokio::time::timeout(AUTH_PERSISTENCE_CHILD_PHASE_TIMEOUT, async { + for (name, task) in [("first", first), ("second", second)] { + match task.await { + Ok(_) => {} + Err(error) if error.is_cancelled() => {} + Err(error) => panic!("{name} invitation task failed: {error}"), } - tokio::task::yield_now().await; } }) .await - .expect("child did not accept its second authorization revision"); - fs::write(queued, b"queued").unwrap(); - database + .expect("invitation tasks retained database ownership after cancellation"); + (database, hooks) }); - runtime.shutdown_timeout(Duration::from_millis(10)); + write_auth_persistence_child_marker(AUTH_PERSISTENCE_QUEUED_MARKER); + wait_for_auth_persistence_release(); + hooks.release(); + // Both task owners were collected while the worker was blocked, so + // runtime drop cannot provide persistence progress or retain ownership. + drop(runtime); drop(database); + write_auth_persistence_child_marker(AUTH_PERSISTENCE_DRAINED_MARKER); } #[test] fn auth_persistence_process_exit_joins_queued_revisions() { - use std::process::{Command, Stdio}; - let temp = tempfile::tempdir().unwrap(); let state_dir = temp.path().join("state"); - let gate = temp.path().join("release-write"); - let queued = temp.path().join("queued"); - let mut child = Command::new(std::env::current_exe().unwrap()) + let child = Command::new(std::env::current_exe().unwrap()) .args(["--exact", "identity::tests::auth_persistence_child_process", "--nocapture"]) .env("CMUX_TEST_AUTH_PERSISTENCE_STATE", &state_dir) - .env("CMUX_TEST_AUTH_PERSISTENCE_GATE", &gate) - .env("CMUX_TEST_AUTH_PERSISTENCE_QUEUED", &queued) - .stdin(Stdio::null()) - .stdout(Stdio::null()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(3); - while !queued.exists() { - if let Some(status) = child.try_wait().unwrap() { - panic!("persistence child exited before queueing its second revision: {status}"); - } - assert!( - std::time::Instant::now() < deadline, - "persistence child did not queue its second revision" - ); - std::thread::sleep(Duration::from_millis(1)); - } - - fs::write(&gate, b"release").unwrap(); - let status = child.wait().unwrap(); + let mut child = AuthPersistenceChildOwner::new(child); + child.wait_for_marker(AUTH_PERSISTENCE_QUEUED_MARKER, "revision-2 submission"); + child.send_release(); + child.wait_for_marker(AUTH_PERSISTENCE_DRAINED_MARKER, "persistence drain"); + let status = child.wait_for_exit(); + child.finish_output(); assert!(status.success(), "persistence child failed: {status}"); let persisted = load_state(&state_dir.join("devices.json")).unwrap(); assert_eq!(persisted.revision, 2); @@ -2955,7 +3100,7 @@ mod tests { .unwrap(); assert_eq!(unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) }, 0); - let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let (sender, receiver) = mpsc::sync_channel(1); let creator = std::thread::spawn(move || { sender.send(load_or_create_identity(&identity_path)).unwrap(); }); @@ -3916,8 +4061,8 @@ mod tests { ); let state = Arc::new(Mutex::new(auth)); let cleanup = PendingDecisionCleanup::new(&state, invitation_id.clone(), token); - let (locked_tx, locked_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (locked_tx, locked_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); let holder = std::thread::spawn({ let state = Arc::clone(&state); move || { @@ -3932,17 +4077,14 @@ mod tests { release_tx.send(()).unwrap(); holder.join().unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(1); + let deadline = Instant::now() + Duration::from_secs(1); loop { if let Ok(locked) = state.try_lock() && !locked.pending.contains_key(&invitation_id) { break; } - assert!( - std::time::Instant::now() < deadline, - "pending enrollment cleanup never completed" - ); + assert!(Instant::now() < deadline, "pending enrollment cleanup never completed"); std::thread::sleep(Duration::from_millis(5)); } } diff --git a/cmux-tui/crates/cmux-remote/src/lib.rs b/cmux-tui/crates/cmux-remote/src/lib.rs index 5e1010c91548..1e85a3f9fbad 100644 --- a/cmux-tui/crates/cmux-remote/src/lib.rs +++ b/cmux-tui/crates/cmux-remote/src/lib.rs @@ -25,6 +25,7 @@ mod mux_input; mod mux_lanes; pub mod observability; mod owner_lock; +mod process_creation; pub mod provider; pub mod secret_file; pub mod secure_directory; diff --git a/cmux-tui/crates/cmux-remote/src/process_creation.rs b/cmux-tui/crates/cmux-remote/src/process_creation.rs new file mode 100644 index 000000000000..f4c77f2dc0fb --- /dev/null +++ b/cmux-tui/crates/cmux-remote/src/process_creation.rs @@ -0,0 +1,9 @@ +//! Process creation coordinated with non-atomic descriptor setup on macOS. + +use std::io; + +/// Starts a Tokio child while holding cmux-tui's process-wide creation guard. +pub(crate) fn spawn(command: &mut tokio::process::Command) -> io::Result { + let _process_creation = cmux_tui_process::ProcessCreationGuard::acquire(); + command.spawn() +} diff --git a/cmux-tui/crates/cmux-remote/src/provider/relay.rs b/cmux-tui/crates/cmux-remote/src/provider/relay.rs index 4a9c829cfa3f..0d1aa94c8aa6 100644 --- a/cmux-tui/crates/cmux-remote/src/provider/relay.rs +++ b/cmux-tui/crates/cmux-remote/src/provider/relay.rs @@ -222,8 +222,8 @@ async fn read_credential_command( .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); - let mut child = - command.spawn().map_err(|_| credential_source_error("command could not be started"))?; + let mut child = crate::process_creation::spawn(&mut command) + .map_err(|_| credential_source_error("command could not be started"))?; let stdout = child .stdout .take() diff --git a/cmux-tui/crates/cmux-remote/src/provider/ssh.rs b/cmux-tui/crates/cmux-remote/src/provider/ssh.rs index 6329a7a9fc76..fab714490544 100644 --- a/cmux-tui/crates/cmux-remote/src/provider/ssh.rs +++ b/cmux-tui/crates/cmux-remote/src/provider/ssh.rs @@ -188,8 +188,7 @@ impl LinkGroup for SshLinkGroup { .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .kill_on_drop(true); - let mut child = command - .spawn() + let mut child = crate::process_creation::spawn(&mut command) .map_err(|error| ProviderError::Transport(format!("could not start ssh: {error}")))?; let stdin = child .stdin diff --git a/cmux-tui/crates/cmux-remote/src/provider/unix.rs b/cmux-tui/crates/cmux-remote/src/provider/unix.rs index 459c99c428c0..421e3e74f236 100644 --- a/cmux-tui/crates/cmux-remote/src/provider/unix.rs +++ b/cmux-tui/crates/cmux-remote/src/provider/unix.rs @@ -2,9 +2,6 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use async_trait::async_trait; -use tokio::net::UnixStream; - use crate::admin::verify_unix_peer_owner; #[cfg(test)] use crate::admin::verify_unix_peer_uid; @@ -14,6 +11,7 @@ use crate::provider::{ CarrierEvidence, ConnectRequest, LengthDelimitedLink, LinkGroup, LinkRequest, ProviderCapabilities, ProviderError, SupportedClientAuthModes, TransportProvider, }; +use async_trait::async_trait; #[derive(Debug, Clone)] pub struct UnixProvider { @@ -120,13 +118,15 @@ impl LinkGroup for UnixLinkGroup { if self.closed.load(Ordering::Acquire) { return Err(ProviderError::Link(LinkError::Closed)); } - let stream = UnixStream::connect(&self.path).await.map_err(|error| { - if retryable_dial_error(&error) { - ProviderError::Link(LinkError::Transport(error.to_string())) - } else { - ProviderError::Transport(error.to_string()) - } - })?; + let stream = cmux_tui_process::tokio_net::connect_unix_stream(&self.path).await.map_err( + |error| { + if retryable_dial_error(&error) { + ProviderError::Link(LinkError::Transport(error.to_string())) + } else { + ProviderError::Transport(error.to_string()) + } + }, + )?; #[cfg(test)] let peer_validation = match self.expected_uid { Some(expected_uid) => verify_unix_peer_uid(&stream, expected_uid), diff --git a/cmux-tui/crates/cmux-remote/src/provider/websocket.rs b/cmux-tui/crates/cmux-remote/src/provider/websocket.rs index 1d82c266b14c..7549e25c9177 100644 --- a/cmux-tui/crates/cmux-remote/src/provider/websocket.rs +++ b/cmux-tui/crates/cmux-remote/src/provider/websocket.rs @@ -11,7 +11,7 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_config}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, client_async_tls_with_config}; use url::Url; use crate::link::{FrameLink, LinkError}; @@ -121,7 +121,21 @@ pub async fn connect_websocket( let description = sanitized_route(endpoint); let config = WebSocketConfig::default().max_message_size(Some(maximum)).max_frame_size(Some(maximum)); - let (socket, _) = connect_async_with_config(endpoint.as_str(), Some(config), true) + let host = endpoint + .host_str() + .ok_or_else(|| LinkError::Transport("WebSocket endpoint has no host".into()))?; + let port = endpoint + .port_or_known_default() + .ok_or_else(|| LinkError::Transport("WebSocket endpoint has no port".into()))?; + let addresses = tokio::net::lookup_host((host, port)) + .await + .map_err(|error| LinkError::Transport(error.to_string()))? + .collect::>(); + let stream = cmux_tui_process::tokio_net::connect_tcp_stream(&addresses) + .await + .map_err(|error| LinkError::Transport(error.to_string()))?; + stream.set_nodelay(true).map_err(|error| LinkError::Transport(error.to_string()))?; + let (socket, _) = client_async_tls_with_config(endpoint.as_str(), stream, Some(config), None) .await .map_err(|error| LinkError::Transport(error.to_string()))?; Ok(TungsteniteWebSocketLink::new(description, maximum, socket)) diff --git a/cmux-tui/crates/cmux-remote/src/services.rs b/cmux-tui/crates/cmux-remote/src/services.rs index 87090aef4058..46ccc5bb1f6a 100644 --- a/cmux-tui/crates/cmux-remote/src/services.rs +++ b/cmux-tui/crates/cmux-remote/src/services.rs @@ -793,7 +793,7 @@ impl DaemonServices { let path = mux_socket.as_ref().ok_or_else(|| { ServicesError::Unavailable("mux control socket is not configured".into()) })?; - let socket = tokio::net::UnixStream::connect(path).await?; + let socket = cmux_tui_process::tokio_net::connect_unix_stream(path).await?; let stream = Arc::new(stream); send_opened(&stream, Lane::Interactive).await?; let (reader, writer) = socket.into_split(); diff --git a/cmux-tui/crates/cmux-remote/src/ssh_bootstrap.rs b/cmux-tui/crates/cmux-remote/src/ssh_bootstrap.rs index eb6c2c840b22..bc2662f49ca4 100644 --- a/cmux-tui/crates/cmux-remote/src/ssh_bootstrap.rs +++ b/cmux-tui/crates/cmux-remote/src/ssh_bootstrap.rs @@ -388,13 +388,12 @@ impl SshBootstrapper { for argument in remote_arguments { command.arg(argument); } - let mut child = command + command .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(BootstrapError::Io)?; + .kill_on_drop(true); + let mut child = crate::process_creation::spawn(&mut command).map_err(BootstrapError::Io)?; let stdout = match child.stdout.take() { Some(stdout) => stdout, None => { @@ -449,13 +448,12 @@ impl SshBootstrapper { command.arg("-p").arg(port.to_string()); } command.args(&self.config.extra_args).arg(&self.config.destination).arg(remote_command); - let mut child = command + command .stdin(Stdio::from(source)) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(BootstrapError::Io)?; + .kill_on_drop(true); + let mut child = crate::process_creation::spawn(&mut command).map_err(BootstrapError::Io)?; let stdout = match child.stdout.take() { Some(stdout) => stdout, None => { @@ -1132,4 +1130,48 @@ mod tests { async fn oversized_ssh_stderr_kills_and_reaps_ssh() { assert_oversized_output_is_bounded("stderr").await; } + + #[cfg(target_os = "macos")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn binary_upload_waits_for_the_process_creation_barrier() { + use std::fs; + + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("cmux-tui"); + fs::write(&source, b"test binary").unwrap(); + let mut config = SshBootstrapConfig::defaults("host"); + config.ssh_binary = "/usr/bin/true".into(); + config.timeout = Duration::from_secs(5); + let bootstrap = SshBootstrapper::new(config).unwrap(); + let (held_sender, held_receiver) = std::sync::mpsc::channel(); + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + held_sender.send(()).unwrap(); + release_receiver.recv().unwrap(); + }); + held_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("process creation barrier was not acquired"); + + let (started_sender, started_receiver) = tokio::sync::oneshot::channel(); + let mut upload = tokio::spawn(async move { + started_sender.send(()).unwrap(); + bootstrap.run_remote_with_input("ignored", &source).await + }); + started_receiver.await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(250), &mut upload).await.is_err(), + "SSH upload spawned a child while descriptor creation held the shared barrier" + ); + release_sender.send(()).unwrap(); + holder.join().unwrap(); + + let output = tokio::time::timeout(Duration::from_secs(5), upload) + .await + .expect("SSH upload did not resume after the process barrier was released") + .unwrap() + .unwrap(); + assert_eq!(output.status, 0); + } } diff --git a/cmux-tui/crates/cmux-remote/src/unix_socket.rs b/cmux-tui/crates/cmux-remote/src/unix_socket.rs index 814c892bf61f..28a0dcbdbe50 100644 --- a/cmux-tui/crates/cmux-remote/src/unix_socket.rs +++ b/cmux-tui/crates/cmux-remote/src/unix_socket.rs @@ -123,7 +123,7 @@ impl OwnedUnixListener { }; remove_stale_socket(&path).await?; - let listener = UnixListener::bind(&path).map_err(|error| { + let listener = cmux_tui_process::tokio_net::bind_unix_listener(&path).map_err(|error| { contextual_io(error, format!("could not bind Unix socket {}", path.display())) })?; let metadata = match fs::symlink_metadata(&path) { @@ -298,7 +298,12 @@ async fn remove_stale_socket(path: &Path) -> Result<(), UnixSocketError> { path.display() ))); } - match tokio::time::timeout(SOCKET_PROBE_TIMEOUT, tokio::net::UnixStream::connect(path)).await { + match tokio::time::timeout( + SOCKET_PROBE_TIMEOUT, + cmux_tui_process::tokio_net::connect_unix_stream(path), + ) + .await + { Ok(Ok(_)) => Err(UnixSocketError::Protocol(format!( "another process is listening at {}", path.display() diff --git a/cmux-tui/crates/cmux-remote/src/workspace/git.rs b/cmux-tui/crates/cmux-remote/src/workspace/git.rs index afc376ec73e6..56b224b686aa 100644 --- a/cmux-tui/crates/cmux-remote/src/workspace/git.rs +++ b/cmux-tui/crates/cmux-remote/src/workspace/git.rs @@ -314,8 +314,7 @@ async fn run_git( }); } } - let mut child = command - .spawn() + let mut child = crate::process_creation::spawn(&mut command) .map_err(|error| RpcError::new("git-unavailable", format!("start git: {error}")))?; let mut stdout = child.stdout.take().ok_or_else(|| RpcError::new("internal", "git stdout was not piped"))?; diff --git a/cmux-tui/crates/cmux-remote/src/workspace/process.rs b/cmux-tui/crates/cmux-remote/src/workspace/process.rs index 94946b7bfcba..eee557fae614 100644 --- a/cmux-tui/crates/cmux-remote/src/workspace/process.rs +++ b/cmux-tui/crates/cmux-remote/src/workspace/process.rs @@ -1254,8 +1254,7 @@ impl ProcessManager { let mut child_events = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::child()) .map_err(|error| RpcError::new("process-spawn-failed", error.to_string()))?; - let mut child = command - .spawn() + let mut child = crate::process_creation::spawn(&mut command) .map_err(|error| RpcError::new("process-spawn-failed", error.to_string()))?; let pid = child.id(); #[cfg(unix)] @@ -2800,6 +2799,56 @@ mod tests { } } + #[cfg(target_os = "macos")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pipe_spawn_waits_for_process_creation_barrier() { + let (directory, root) = root().await; + let manager = Arc::new(ProcessManager::default()); + let pid_file = directory.path().join("barrier-pipe.pid"); + let (barrier_held_sender, barrier_held_receiver) = std::sync::mpsc::channel(); + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let barrier_holder = std::thread::spawn(move || { + let _barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + barrier_held_sender.send(()).unwrap(); + release_receiver.recv().unwrap(); + }); + barrier_held_receiver + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("process creation barrier was not acquired"); + + let mut options = spawn_options( + vec![ + "/bin/sh".into(), + "-c".into(), + "printf '%s' \"$$\" > \"$PIDFILE\"; exec sleep 30".into(), + ], + ProcessIo::Pipes { stdin: false }, + ProcessLifetime::Workspace, + ); + options.env.insert("PIDFILE".into(), pid_file.to_string_lossy().into_owned()); + let spawn_manager = manager.clone(); + let (started_sender, started_receiver) = oneshot::channel(); + let mut spawn = tokio::spawn(async move { + started_sender.send(()).unwrap(); + spawn_manager.spawn(root, options).await + }); + started_receiver.await.unwrap(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(250), &mut spawn).await.is_err(), + "remote pipe child started while the process barrier was held" + ); + assert!(!pid_file.exists(), "blocked remote pipe child published its pid"); + release_sender.send(()).unwrap(); + barrier_holder.join().unwrap(); + + tokio::time::timeout(std::time::Duration::from_secs(5), spawn) + .await + .expect("pipe spawn did not resume after the process barrier was released") + .unwrap() + .unwrap(); + manager.shutdown().await; + } + #[cfg(unix)] #[test] fn pty_identity_uses_the_direct_child_instead_of_the_foreground_job() { diff --git a/cmux-tui/crates/cmux-remote/src/workspace/route.rs b/cmux-tui/crates/cmux-remote/src/workspace/route.rs index 26e5ffdf5d68..6483182e68da 100644 --- a/cmux-tui/crates/cmux-remote/src/workspace/route.rs +++ b/cmux-tui/crates/cmux-remote/src/workspace/route.rs @@ -92,10 +92,13 @@ impl RouteManager { .cloned() .ok_or_else(|| RpcError::new("unknown-route", format!("unknown route {}", id.0)))?; let addresses = resolve_allowed(&route.host, route.port, route.policy).await?; - let stream = tokio::time::timeout(DIAL_TIMEOUT, TcpStream::connect(addresses.as_slice())) - .await - .map_err(|_| RpcError::new("route-unavailable", "connection timed out"))? - .map_err(|error| RpcError::new("route-unavailable", error.to_string()))?; + let stream = tokio::time::timeout( + DIAL_TIMEOUT, + cmux_tui_process::tokio_net::connect_tcp_stream(addresses.as_slice()), + ) + .await + .map_err(|_| RpcError::new("route-unavailable", "connection timed out"))? + .map_err(|error| RpcError::new("route-unavailable", error.to_string()))?; stream .set_nodelay(true) .map_err(|error| RpcError::new("route-error", error.to_string()))?; diff --git a/cmux-tui/crates/cmux-tui-cdp/Cargo.toml b/cmux-tui/crates/cmux-tui-cdp/Cargo.toml index 3a5ecbecdc3a..b30c4199e477 100644 --- a/cmux-tui/crates/cmux-tui-cdp/Cargo.toml +++ b/cmux-tui/crates/cmux-tui-cdp/Cargo.toml @@ -13,5 +13,7 @@ workspace = true [dependencies] anyhow.workspace = true base64.workspace = true +cmux-tui-process.workspace = true +libc.workspace = true serde_json.workspace = true tungstenite.workspace = true diff --git a/cmux-tui/crates/cmux-tui-cdp/src/chrome.rs b/cmux-tui/crates/cmux-tui-cdp/src/chrome.rs index 24e1854731fa..71cebe1474b3 100644 --- a/cmux-tui/crates/cmux-tui-cdp/src/chrome.rs +++ b/cmux-tui/crates/cmux-tui-cdp/src/chrome.rs @@ -2,12 +2,41 @@ use std::ffi::OsString; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; -use std::sync::Mutex; -use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(test)] +use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; static PROFILE_SEQ: AtomicU64 = AtomicU64::new(1); +static CHROME_REAPER: OnceLock>> = OnceLock::new(); +const REAPER_CAPACITY: usize = 4_096; +const REAPER_INITIAL_BACKOFF: Duration = Duration::from_millis(10); +const REAPER_MAX_BACKOFF: Duration = Duration::from_secs(1); + +#[cfg(test)] +static FORCE_REAPER_PENDING: AtomicBool = AtomicBool::new(false); +#[cfg(test)] +static FORCE_REAPER_WAIT_ERROR: AtomicBool = AtomicBool::new(false); +#[cfg(all(test, unix))] +static REAPER_TEST_LOCK: Mutex<()> = Mutex::new(()); +#[cfg(test)] +static REAPER_TEST_EVENT_SENDER: Mutex>> = Mutex::new(None); + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReaperTestEvent { + Attempt(u32), + Complete(u32), +} + +#[cfg(test)] +thread_local! { + static FORCE_KILL_TIMEOUT: std::cell::Cell = const { std::cell::Cell::new(false) }; + static FORCE_REAPER_SPAWN_FAILURE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} #[derive(Debug, Clone)] pub struct ChromeLaunchOptions { @@ -26,12 +55,18 @@ pub enum BrowserMode { /// A launched Chrome/Chromium process plus its profile dir. pub struct Chrome { - child: Mutex>, + process: Mutex, profile_dir: PathBuf, profile_ephemeral: bool, web_socket_url: String, } +enum ChromeProcessState { + Owned { child: Child, reaper: ChromeReaperLease }, + Reaping { completion: mpsc::Receiver<()> }, + Complete, +} + impl Chrome { /// Launch Chrome (headful by default, headless when the launch /// options request it) and wait for the browser CDP endpoint printed @@ -46,50 +81,67 @@ impl Chrome { } pub fn launch_with(options: &ChromeLaunchOptions) -> anyhow::Result { + // Browser shutdown must never depend on creating a new thread. Refuse + // to launch the child until the process-wide reaper owns a worker and + // has reserved bounded cleanup capacity for this exact process. + let reaper = chrome_reaper_lease().map_err(|error| { + anyhow::anyhow!("cannot launch Chrome without bounded cleanup ownership: {error}") + })?; let (profile_dir, profile_ephemeral) = profile_dir_for(options)?; std::fs::create_dir_all(&profile_dir)?; let mut command = Command::new(&options.binary); - command.args(chrome_args_for(&profile_dir, options.mode)); - let mut child = command + command + .args(chrome_args_for(&profile_dir, options.mode)) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| { - anyhow::anyhow!("failed to launch Chrome at {}: {e}", options.binary.display()) - })?; + .stderr(Stdio::piped()); + let mut child = cmux_tui_process::spawn(&mut command).map_err(|e| { + anyhow::anyhow!("failed to launch Chrome at {}: {e}", options.binary.display()) + })?; let stderr = child .stderr .take() .ok_or_else(|| anyhow::anyhow!("failed to capture Chrome stderr"))?; let (tx, rx) = mpsc::channel(); - std::thread::Builder::new().name("cmux-tui-cdp-chrome-stderr".into()).spawn(move || { - let mut reader = BufReader::new(stderr); - let mut line = String::new(); - let mut sent = false; - loop { - line.clear(); - match reader.read_line(&mut line) { - Ok(0) | Err(_) => break, - Ok(_) => { - if !sent && let Some(url) = parse_devtools_url(&line) { - let _ = tx.send(url); - sent = true; + let stderr_worker = std::thread::Builder::new() + .name("cmux-tui-cdp-chrome-stderr".into()) + .spawn(move || { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + let mut sent = false; + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => { + if !sent && let Some(url) = parse_devtools_url(&line) { + let _ = tx.send(url); + sent = true; + } } } } - } - })?; + }); + if let Err(error) = stderr_worker { + let _ = reap_child_until( + reaper, + child, + profile_ephemeral.then(|| profile_dir.clone()), + Instant::now() + Duration::from_secs(1), + ); + return Err(error.into()); + } let web_socket_url = match rx.recv_timeout(Duration::from_secs(10)) { Ok(url) => url, Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - if profile_ephemeral { - let _ = std::fs::remove_dir_all(&profile_dir); - } + let _ = reap_child_until( + reaper, + child, + profile_ephemeral.then(|| profile_dir.clone()), + Instant::now() + Duration::from_secs(1), + ); anyhow::bail!( "Chrome did not publish a DevTools endpoint within 10s (binary: {})", options.binary.display() @@ -98,7 +150,7 @@ impl Chrome { }; Ok(Chrome { - child: Mutex::new(Some(child)), + process: Mutex::new(ChromeProcessState::Owned { child, reaper }), profile_dir, profile_ephemeral, web_socket_url, @@ -110,18 +162,316 @@ impl Chrome { } pub fn kill(&self) { - if let Some(mut child) = self.child.lock().unwrap().take() { - let _ = child.kill(); - let _ = child.wait(); + let _ = self.kill_until(Instant::now() + Duration::from_secs(1)); + } + + pub fn kill_until(&self, deadline: Instant) -> bool { + let mut state = self.process.lock().unwrap(); + if matches!(&*state, ChromeProcessState::Owned { .. }) { + let ChromeProcessState::Owned { child, reaper } = + std::mem::replace(&mut *state, ChromeProcessState::Complete) + else { + unreachable!() + }; + let completion = reap_child_with_completion( + reaper, + child, + self.profile_ephemeral.then(|| self.profile_dir.clone()), + ); + *state = ChromeProcessState::Reaping { completion }; + } + let completed = match &*state { + ChromeProcessState::Owned { .. } => unreachable!(), + ChromeProcessState::Reaping { completion } => { + wait_for_reap_completion(completion, deadline) + } + ChromeProcessState::Complete => true, + }; + if completed { + *state = ChromeProcessState::Complete; } + completed } } impl Drop for Chrome { fn drop(&mut self) { - self.kill(); - if self.profile_ephemeral { - let _ = std::fs::remove_dir_all(&self.profile_dir); + let state = self.process.get_mut().unwrap_or_else(|poisoned| poisoned.into_inner()); + let state = std::mem::replace(state, ChromeProcessState::Complete); + if let ChromeProcessState::Owned { child, reaper } = state { + reap_child_detached( + reaper, + child, + self.profile_ephemeral.then(|| self.profile_dir.clone()), + ); + } + } +} + +struct ReapRequest { + child: Child, + profile_dir: Option, + completion: Option>, + lease: Option, + next_attempt: Instant, + retry_delay: Duration, +} + +struct ChromeReaperLease { + sender: mpsc::Sender, + active: Arc, +} + +impl Drop for ChromeReaperLease { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} + +struct ChromeReaper { + sender: mpsc::Sender, + active: Arc, + _worker: JoinHandle<()>, +} + +impl ChromeReaper { + fn start() -> std::io::Result { + let (sender, receiver) = mpsc::channel(); + let worker = spawn_reaper_worker(receiver)?; + Ok(Self { sender, active: Arc::new(AtomicUsize::new(0)), _worker: worker }) + } + + fn lease(&self) -> std::io::Result { + reserve_reaper_lease(self.sender.clone(), self.active.clone(), REAPER_CAPACITY) + } +} + +fn spawn_reaper_worker(receiver: mpsc::Receiver) -> std::io::Result> { + #[cfg(test)] + let force_spawn_failure = FORCE_REAPER_SPAWN_FAILURE.get(); + #[cfg(not(test))] + let force_spawn_failure = false; + if force_spawn_failure { + return Err(std::io::Error::other("forced Chrome reaper spawn failure")); + } + std::thread::Builder::new() + .name("cmux-tui-cdp-chrome-reaper".into()) + .spawn(move || run_reaper(receiver)) +} + +fn reserve_reaper_lease( + sender: mpsc::Sender, + active: Arc, + capacity: usize, +) -> std::io::Result { + active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + (current < capacity).then_some(current + 1) + }) + .map_err(|_| { + std::io::Error::new(std::io::ErrorKind::WouldBlock, "Chrome reaper capacity exhausted") + })?; + Ok(ChromeReaperLease { sender, active }) +} + +fn chrome_reaper_lease() -> std::io::Result { + let mut slot = CHROME_REAPER.get_or_init(|| Mutex::new(None)).lock().unwrap(); + if slot.is_none() { + *slot = Some(ChromeReaper::start()?); + } + slot.as_ref().expect("Chrome reaper initialized").lease() +} + +fn reap_child_detached(reaper: ChromeReaperLease, child: Child, profile_dir: Option) { + reap_child(reaper, child, profile_dir, None); +} + +fn reap_child_until( + reaper: ChromeReaperLease, + child: Child, + profile_dir: Option, + deadline: Instant, +) -> bool { + let completion = reap_child_with_completion(reaper, child, profile_dir); + wait_for_reap_completion(&completion, deadline) +} + +fn reap_child_with_completion( + reaper: ChromeReaperLease, + child: Child, + profile_dir: Option, +) -> mpsc::Receiver<()> { + let (completion_tx, completion_rx) = mpsc::sync_channel(1); + reap_child(reaper, child, profile_dir, Some(completion_tx)); + completion_rx +} + +fn wait_for_reap_completion(completion: &mpsc::Receiver<()>, deadline: Instant) -> bool { + #[cfg(test)] + if FORCE_KILL_TIMEOUT.get() { + return false; + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { return false }; + completion.recv_timeout(remaining).is_ok() +} + +fn reap_child( + reaper: ChromeReaperLease, + child: Child, + profile_dir: Option, + completion: Option>, +) { + let sender = reaper.sender.clone(); + sender + .send(ReapRequest { + child, + profile_dir, + completion, + lease: Some(reaper), + next_attempt: Instant::now(), + retry_delay: REAPER_INITIAL_BACKOFF, + }) + .expect("Chrome reaper worker remains alive while its service is registered"); +} + +fn run_reaper(receiver: mpsc::Receiver) { + let mut pending: Vec = Vec::new(); + loop { + let received = if pending.is_empty() { + receiver.recv().map_err(|_| mpsc::RecvTimeoutError::Disconnected) + } else { + let now = Instant::now(); + let wait = pending + .iter() + .map(|request| request.next_attempt.saturating_duration_since(now)) + .min() + .unwrap_or(REAPER_MAX_BACKOFF); + receiver.recv_timeout(wait) + }; + match received { + Ok(request) => pending.push(request), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) if pending.is_empty() => return, + Err(mpsc::RecvTimeoutError::Disconnected) => { + std::thread::sleep(REAPER_MAX_BACKOFF); + } + } + while let Ok(request) = receiver.try_recv() { + pending.push(request); + } + let now = Instant::now(); + pending.retain_mut(|request| { + if request.next_attempt > now { + return true; + } + if poll_reap_request(request) { + return false; + } + request.next_attempt = now + request.retry_delay; + request.retry_delay = next_reaper_retry_delay(request.retry_delay); + true + }); + } +} + +fn next_reaper_retry_delay(current: Duration) -> Duration { + (current * 2).min(REAPER_MAX_BACKOFF) +} + +fn poll_reap_request(request: &mut ReapRequest) -> bool { + #[cfg(test)] + publish_reaper_test_event(ReaperTestEvent::Attempt(request.child.id())); + #[cfg(test)] + { + if FORCE_REAPER_PENDING.load(Ordering::Acquire) { + return false; + } + } + match chrome_child_try_wait(request) { + Ok(Some(_)) => {} + Err(error) if chrome_child_wait_lost_ownership(&error) => {} + Ok(None) => { + let _ = request.child.kill(); + return false; + } + Err(_) => return false, + } + if let Some(profile_dir) = request.profile_dir.take() { + let _ = std::fs::remove_dir_all(profile_dir); + } + drop(request.lease.take()); + if let Some(completion) = request.completion.take() { + let _ = completion.send(()); + } + #[cfg(test)] + publish_reaper_test_event(ReaperTestEvent::Complete(request.child.id())); + true +} + +fn chrome_child_wait_lost_ownership(error: &std::io::Error) -> bool { + #[cfg(unix)] + { + error.raw_os_error() == Some(libc::ECHILD) + } + #[cfg(not(unix))] + { + let _ = error; + false + } +} + +fn chrome_child_try_wait( + request: &mut ReapRequest, +) -> std::io::Result> { + #[cfg(test)] + if FORCE_REAPER_WAIT_ERROR.load(Ordering::Acquire) { + return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); + } + request.child.try_wait() +} + +#[cfg(test)] +fn publish_reaper_test_event(event: ReaperTestEvent) { + if let Some(sender) = REAPER_TEST_EVENT_SENDER.lock().unwrap().as_ref() { + let _ = sender.send(event); + } +} + +#[cfg(test)] +struct ReaperTestObserver; + +#[cfg(test)] +impl ReaperTestObserver { + fn install() -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(); + let previous = REAPER_TEST_EVENT_SENDER.lock().unwrap().replace(sender); + assert!(previous.is_none(), "Chrome reaper test observer was already installed"); + (Self, receiver) + } +} + +#[cfg(test)] +impl Drop for ReaperTestObserver { + fn drop(&mut self) { + REAPER_TEST_EVENT_SENDER.lock().unwrap().take(); + } +} + +#[cfg(test)] +fn wait_for_reaper_test_event( + receiver: &mpsc::Receiver, + expected: ReaperTestEvent, +) { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("Chrome reaper did not publish the expected lifecycle event"); + let event = receiver + .recv_timeout(remaining) + .expect("Chrome reaper did not publish the expected lifecycle event"); + if event == expected { + return; } } } @@ -182,6 +532,24 @@ fn parse_devtools_url(line: &str) -> Option { mod tests { use super::*; + #[cfg(unix)] + struct ForcedReaperPending; + + #[cfg(unix)] + impl ForcedReaperPending { + fn new() -> Self { + FORCE_REAPER_PENDING.store(true, Ordering::Release); + Self + } + } + + #[cfg(unix)] + impl Drop for ForcedReaperPending { + fn drop(&mut self) { + FORCE_REAPER_PENDING.store(false, Ordering::Release); + } + } + #[test] fn parses_devtools_endpoint() { assert_eq!( @@ -260,4 +628,328 @@ mod tests { assert!(!args.iter().any(|arg| arg == "--window-size=1280,900")); assert_eq!(args.last().map(String::as_str), Some("about:blank")); } + + #[cfg(unix)] + #[test] + fn kill_until_confirms_the_owned_process_within_its_deadline() { + let child = Command::new("sleep") + .arg("60") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let profile_dir = make_profile_dir().unwrap(); + let chrome = Chrome { + process: Mutex::new(ChromeProcessState::Owned { + child, + reaper: chrome_reaper_lease().unwrap(), + }), + profile_dir, + profile_ephemeral: true, + web_socket_url: "ws://127.0.0.1/unused".to_string(), + }; + + assert!(chrome.kill_until(Instant::now() + Duration::from_secs(1))); + assert!(matches!(&*chrome.process.lock().unwrap(), ChromeProcessState::Complete)); + } + + #[cfg(unix)] + #[test] + fn confirmed_kill_releases_reaper_capacity_before_chrome_drop() { + let child = Command::new("sleep") + .arg("60") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let (sender, receiver) = mpsc::channel(); + let worker = std::thread::spawn(move || run_reaper(receiver)); + let active = Arc::new(AtomicUsize::new(0)); + let reaper = reserve_reaper_lease(sender.clone(), active.clone(), 1).unwrap(); + let chrome = Chrome { + process: Mutex::new(ChromeProcessState::Owned { child, reaper }), + profile_dir: make_profile_dir().unwrap(), + profile_ephemeral: true, + web_socket_url: "ws://127.0.0.1/unused".to_string(), + }; + + assert!(chrome.kill_until(Instant::now() + Duration::from_secs(1))); + assert_eq!( + active.load(Ordering::Acquire), + 0, + "a confirmed child retained bounded reaper capacity until Chrome was dropped" + ); + drop(chrome); + drop(sender); + worker.join().unwrap(); + } + + #[cfg(unix)] + #[test] + fn drop_transfers_an_unconfirmed_child_to_a_reaper() { + let _guard = REAPER_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ReaperTestObserver::install(); + unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32; + } + + let child = Command::new("sleep") + .arg("60") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = i32::try_from(child.id()).unwrap(); + let chrome = Chrome { + process: Mutex::new(ChromeProcessState::Owned { + child, + reaper: chrome_reaper_lease().unwrap(), + }), + profile_dir: make_profile_dir().unwrap(), + profile_ephemeral: true, + web_socket_url: "ws://127.0.0.1/unused".to_string(), + }; + + FORCE_KILL_TIMEOUT.set(true); + assert!(!chrome.kill_until(Instant::now() + Duration::from_secs(1))); + FORCE_KILL_TIMEOUT.set(false); + drop(chrome); + wait_for_reaper_test_event(&events, ReaperTestEvent::Complete(pid as u32)); + let reaped = unsafe { kill(pid, 0) } != 0; + if !reaped { + let mut status = 0; + unsafe { + waitpid(pid, &mut status, 0); + } + } + + assert!(reaped, "dropping Chrome discarded an unconfirmed child without reaping it"); + } + + #[cfg(unix)] + #[test] + fn kill_retry_waits_for_reaper_completion_before_reporting_success() { + let _guard = REAPER_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ReaperTestObserver::install(); + let child = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = child.id(); + let chrome = Chrome { + process: Mutex::new(ChromeProcessState::Owned { + child, + reaper: chrome_reaper_lease().unwrap(), + }), + profile_dir: make_profile_dir().unwrap(), + profile_ephemeral: true, + web_socket_url: "ws://127.0.0.1/unused".to_string(), + }; + let pending = ForcedReaperPending::new(); + + FORCE_KILL_TIMEOUT.set(true); + let first = chrome.kill_until(Instant::now() + Duration::from_secs(1)); + FORCE_KILL_TIMEOUT.set(false); + wait_for_reaper_test_event(&events, ReaperTestEvent::Attempt(pid)); + + FORCE_KILL_TIMEOUT.set(true); + let second = chrome.kill_until(Instant::now() + Duration::from_secs(1)); + FORCE_KILL_TIMEOUT.set(false); + + drop(pending); + wait_for_reaper_test_event(&events, ReaperTestEvent::Complete(pid)); + let third = chrome.kill_until(Instant::now() + Duration::from_secs(1)); + + assert!(!first, "the first forced timeout reported completion"); + assert!(!second, "a retry reported success before the reaper confirmed process exit"); + assert!(third, "a retry did not report success after confirmed process exit"); + } + + #[cfg(unix)] + #[test] + fn reaper_spawn_failure_never_waits_on_the_caller_thread() { + let _guard = REAPER_TEST_LOCK.lock().unwrap(); + FORCE_REAPER_SPAWN_FAILURE.set(true); + let started = Instant::now(); + let result = ChromeReaper::start(); + FORCE_REAPER_SPAWN_FAILURE.set(false); + + assert!(result.is_err(), "the forced reaper spawn failure was not exercised"); + assert!( + started.elapsed() < Duration::from_millis(250), + "reaper spawn failure waited on caller-thread cleanup" + ); + } + + #[cfg(unix)] + #[test] + fn final_reaper_request_survives_a_shutdown_thread_spawn_failure() { + let _guard = REAPER_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ReaperTestObserver::install(); + unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32; + } + + let warmup = Command::new("true").spawn().unwrap(); + reap_child_detached(chrome_reaper_lease().unwrap(), warmup, None); + + let child = Command::new("sleep") + .arg("60") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = i32::try_from(child.id()).unwrap(); + let profile_dir = make_profile_dir().unwrap(); + + FORCE_REAPER_SPAWN_FAILURE.set(true); + reap_child_detached(chrome_reaper_lease().unwrap(), child, Some(profile_dir.clone())); + FORCE_REAPER_SPAWN_FAILURE.set(false); + + wait_for_reaper_test_event(&events, ReaperTestEvent::Complete(pid as u32)); + let cleaned = unsafe { kill(pid, 0) } != 0 && !profile_dir.exists(); + if !cleaned { + let mut status = 0; + unsafe { + kill(pid, 9); + waitpid(pid, &mut status, 0); + } + let _ = std::fs::remove_dir_all(&profile_dir); + } + + assert!(cleaned, "the final Chrome cleanup remained parked until another browser shutdown"); + } + + #[cfg(unix)] + #[test] + fn persistent_reaper_backs_off_unconfirmed_children() { + let _guard = REAPER_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ReaperTestObserver::install(); + unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32; + } + + let child = Command::new("sleep") + .arg("60") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = i32::try_from(child.id()).unwrap(); + FORCE_REAPER_PENDING.store(true, Ordering::Release); + reap_child_detached(chrome_reaper_lease().unwrap(), child, None); + wait_for_reaper_test_event(&events, ReaperTestEvent::Attempt(pid as u32)); + FORCE_REAPER_PENDING.store(false, Ordering::Release); + wait_for_reaper_test_event(&events, ReaperTestEvent::Complete(pid as u32)); + if unsafe { kill(pid, 0) } == 0 { + let mut status = 0; + unsafe { + kill(pid, 9); + waitpid(pid, &mut status, 0); + } + } + assert_eq!(next_reaper_retry_delay(REAPER_INITIAL_BACKOFF), Duration::from_millis(20)); + assert_eq!(next_reaper_retry_delay(REAPER_MAX_BACKOFF), REAPER_MAX_BACKOFF); + } + + #[cfg(unix)] + #[test] + fn reaper_releases_capacity_after_child_wait_ownership_is_lost() { + let _guard = REAPER_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ReaperTestObserver::install(); + drop(chrome_reaper_lease().unwrap()); + let active = { + let slot = CHROME_REAPER.get().unwrap().lock().unwrap(); + slot.as_ref().unwrap().active.clone() + }; + let baseline = active.load(Ordering::Acquire); + let lease = chrome_reaper_lease().unwrap(); + assert_eq!(active.load(Ordering::Acquire), baseline + 1); + + unsafe extern "C" { + fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32; + } + let mut child = Command::new("/bin/sh") + .args(["-c", "exit 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = i32::try_from(child.id()).unwrap(); + let mut status = 0; + // SAFETY: pid identifies this live test child and status is writable. + assert_eq!(unsafe { waitpid(pid, &raw mut status, 0) }, pid); + assert_eq!(child.try_wait().unwrap_err().raw_os_error(), Some(libc::ECHILD)); + let profile_dir = make_profile_dir().unwrap(); + + reap_child_detached(lease, child, Some(profile_dir.clone())); + wait_for_reaper_test_event(&events, ReaperTestEvent::Complete(pid as u32)); + + assert_eq!(active.load(Ordering::Acquire), baseline); + assert!(!profile_dir.exists(), "ownership loss retained the Chrome profile directory"); + } + + #[cfg(unix)] + #[test] + fn terminal_reaper_wait_error_retains_ownership_for_retry() { + let _guard = REAPER_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ReaperTestObserver::install(); + unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32; + } + + let child = Command::new("sleep") + .arg("60") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = i32::try_from(child.id()).unwrap(); + let profile_dir = make_profile_dir().unwrap(); + FORCE_REAPER_WAIT_ERROR.store(true, Ordering::Release); + reap_child_detached(chrome_reaper_lease().unwrap(), child, Some(profile_dir.clone())); + wait_for_reaper_test_event(&events, ReaperTestEvent::Attempt(pid as u32)); + let retained = profile_dir.exists(); + FORCE_REAPER_WAIT_ERROR.store(false, Ordering::Release); + wait_for_reaper_test_event(&events, ReaperTestEvent::Complete(pid as u32)); + let mut status = 0; + unsafe { + kill(pid, 9); + waitpid(pid, &mut status, 0); + } + let _ = std::fs::remove_dir_all(&profile_dir); + + assert!(retained, "terminal wait error discarded Chrome cleanup ownership"); + } + + #[test] + fn reaper_admission_is_bounded_by_live_leases() { + let (sender, _receiver) = mpsc::channel::(); + let active = Arc::new(AtomicUsize::new(0)); + let first = reserve_reaper_lease(sender.clone(), active.clone(), 2).unwrap(); + let second = reserve_reaper_lease(sender.clone(), active.clone(), 2).unwrap(); + + assert_eq!(active.load(Ordering::Acquire), 2); + assert!(reserve_reaper_lease(sender.clone(), active.clone(), 2).is_err()); + + drop(first); + let replacement = reserve_reaper_lease(sender, active.clone(), 2).unwrap(); + assert_eq!(active.load(Ordering::Acquire), 2); + drop(second); + drop(replacement); + assert_eq!(active.load(Ordering::Acquire), 0); + } } diff --git a/cmux-tui/crates/cmux-tui-cdp/src/client.rs b/cmux-tui/crates/cmux-tui-cdp/src/client.rs index b893655cecf4..cfae92c25f99 100644 --- a/cmux-tui/crates/cmux-tui-cdp/src/client.rs +++ b/cmux-tui/crates/cmux-tui-cdp/src/client.rs @@ -1,9 +1,13 @@ use std::collections::{HashMap, VecDeque}; use std::io::{Read, Write}; use std::mem::size_of; -use std::net::{TcpStream, ToSocketAddrs}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, Sender, SyncSender, TryRecvError, TrySendError, channel}; +use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::mpsc::{ + Receiver, RecvTimeoutError, Sender, SyncSender, TryRecvError, TrySendError, channel, + sync_channel, +}; use std::sync::{Arc, Condvar, Mutex, Weak}; use std::time::{Duration, Instant}; @@ -20,6 +24,14 @@ use tungstenite::{Error as WsError, Message, WebSocket, client}; /// between CDP layers cannot expand the maximum pending event count. pub const CDP_EVENT_QUEUE_CAPACITY: usize = 64; const CDP_INGRESS_EVENT_CAPACITY: usize = 1024; +const CDP_RESOLVER_QUEUE_CAPACITY: usize = 64; +const CDP_RESOLVER_WORKERS: usize = 4; +const CDP_RESOLVER_CHILD_CAPACITY: usize = CDP_RESOLVER_QUEUE_CAPACITY + CDP_RESOLVER_WORKERS; +const CDP_RESOLVER_POLL_INTERVAL: Duration = Duration::from_millis(5); +const CDP_RESOLVER_REAP_MAX_BACKOFF: Duration = Duration::from_millis(250); +const CDP_RESOLVER_REAP_RETRY_WINDOW: Duration = Duration::from_secs(2); +const CDP_RESOLVER_OUTPUT_LIMIT: u64 = 64 * 1024; +const CDP_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2); // Navigation may invalidate a frame tree while its response is in flight. // Bound retries so a continuously navigating page cannot block the caller forever. const MAIN_FRAME_SNAPSHOT_ATTEMPTS: usize = 8; @@ -36,6 +48,596 @@ const SCREENCAST_CLOCK_RECOVERY_BUDGET: Duration = Duration::from_secs(1); #[cfg(test)] static RETAINED_SIZE_CALLS: AtomicU64 = AtomicU64::new(0); +#[cfg(test)] +static NEXT_RESOLVE_GATE: Mutex>> = Mutex::new(None); +#[cfg(test)] +static NEXT_RESOLVER_INIT_GATE: Mutex>> = Mutex::new(None); +#[cfg(test)] +type TestHostResolverCommand = Arc Command + Send + Sync>; +#[cfg(test)] +static TEST_HOST_RESOLVER_COMMAND: Mutex> = Mutex::new(None); +#[cfg(test)] +static TEST_RESOLVER_REAPER_WAIT_ERROR: Mutex> = Mutex::new(None); +#[cfg(test)] +static TEST_RESOLVER_REAPER_EVENT_SENDER: Mutex>> = + Mutex::new(None); + +#[cfg(test)] +#[derive(Default)] +struct ResolverTestGate { + state: Mutex<(bool, bool)>, + changed: Condvar, +} + +#[cfg(test)] +impl ResolverTestGate { + fn enter_and_wait(&self) { + let mut state = self.state.lock().unwrap(); + state.0 = true; + self.changed.notify_all(); + state = self.changed.wait_while(state, |state| !state.1).unwrap(); + drop(state); + } + + fn wait_until_entered(&self) { + let state = self.state.lock().unwrap(); + let (state, timeout) = self + .changed + .wait_timeout_while(state, Duration::from_secs(1), |state| !state.0) + .unwrap(); + assert!(state.0 && !timeout.timed_out(), "resolver did not enter its test gate"); + } + + fn release(&self) { + let mut state = self.state.lock().unwrap(); + state.1 = true; + self.changed.notify_all(); + } +} + +#[cfg(test)] +#[derive(Default)] +struct ReaderStoppedSignal { + stopped: Mutex, + changed: Condvar, +} + +#[cfg(test)] +impl ReaderStoppedSignal { + fn mark(&self) { + let mut stopped = self.stopped.lock().unwrap(); + *stopped = true; + self.changed.notify_all(); + } + + fn wait(&self, timeout: Duration) -> bool { + let stopped = self.stopped.lock().unwrap(); + let (stopped, _) = + self.changed.wait_timeout_while(stopped, timeout, |stopped| !*stopped).unwrap(); + *stopped + } +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResolverReaperTestEvent { + Attempt(u32), + Complete(u32), +} + +static CDP_RESOLVER: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); +static CDP_RESOLVER_CHILD_REAPER: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +struct CdpResolver { + workers: Arc<(Mutex, Condvar)>, + _initializer: std::thread::JoinHandle<()>, +} + +enum ResolverState { + Initializing, + Ready(SyncSender), + Failed(String), +} + +struct ResolveRequest { + host: String, + port: u16, + deadline: Instant, + response: SyncSender>>, + #[cfg(test)] + gate: Option>, +} + +struct ResolverChildReaper { + sender: Sender, + active: Arc, + degraded: Arc, + _worker: std::thread::JoinHandle<()>, +} + +struct ResolverChildReaperLease { + sender: Sender, + active: Arc, +} + +impl Drop for ResolverChildReaperLease { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} + +struct ResolverChildReapRequest { + child: Child, + _lease: ResolverChildReaperLease, + next_attempt: Instant, + retry_delay: Duration, + retry_deadline: Instant, +} + +impl CdpResolver { + fn spawn() -> std::io::Result { + let workers = Arc::new((Mutex::new(ResolverState::Initializing), Condvar::new())); + let initializer_state = workers.clone(); + #[cfg(test)] + let initializer_gate = NEXT_RESOLVER_INIT_GATE.lock().unwrap().take(); + let initializer = std::thread::Builder::new() + .name("cmux-tui-cdp-resolver-init".into()) + .spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + #[cfg(test)] + if let Some(gate) = initializer_gate { + gate.enter_and_wait(); + } + spawn_host_resolver_workers().map_err(|error| error.to_string()) + })) + .unwrap_or_else(|_| Err("resolver initializer panicked".to_string())); + let (state, ready) = &*initializer_state; + *state.lock().unwrap() = match result { + Ok(sender) => ResolverState::Ready(sender), + Err(error) => ResolverState::Failed(error), + }; + ready.notify_all(); + })?; + Ok(Self { workers, _initializer: initializer }) + } + + fn submit(&self, request: ResolveRequest) -> anyhow::Result<()> { + let sender = self.workers_until(request.deadline)?; + sender.try_send(request).map_err(|error| match error { + TrySendError::Full(_) => anyhow::anyhow!("CDP resolver queue is full"), + TrySendError::Disconnected(_) => anyhow::anyhow!("CDP resolver workers stopped"), + }) + } + + fn workers_until(&self, deadline: Instant) -> anyhow::Result> { + let (state, ready) = &*self.workers; + let mut state = state.lock().unwrap(); + loop { + match &*state { + ResolverState::Ready(sender) => return Ok(sender.clone()), + ResolverState::Failed(error) => { + anyhow::bail!("CDP resolver initialization failed: {error}") + } + ResolverState::Initializing => {} + } + let remaining = remaining_until(deadline, "CDP address resolution deadline expired")?; + let (next, timeout) = ready.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && matches!(*state, ResolverState::Initializing) { + anyhow::bail!("CDP address resolution deadline expired"); + } + } + } + + fn initialization_failed(&self) -> bool { + let state = self.workers.0.lock().unwrap(); + matches!(&*state, ResolverState::Failed(_)) + } + + #[cfg(test)] + fn wait_until_initialized(&self) { + let (state, ready) = &*self.workers; + let state = state.lock().unwrap(); + let (state, timeout) = ready + .wait_timeout_while(state, Duration::from_secs(1), |state| { + matches!(state, ResolverState::Initializing) + }) + .unwrap(); + assert!( + !matches!(*state, ResolverState::Initializing) && !timeout.timed_out(), + "resolver initialization did not finish" + ); + } +} + +fn spawn_host_resolver_workers() -> std::io::Result> { + resolver_child_reaper()?; + let (sender, receiver) = sync_channel(CDP_RESOLVER_QUEUE_CAPACITY); + let receiver = Arc::new(Mutex::new(receiver)); + for index in 0..CDP_RESOLVER_WORKERS { + let receiver = receiver.clone(); + std::thread::Builder::new() + .name(format!("cmux-tui-cdp-resolver-{index}")) + .spawn(move || host_resolver_worker(receiver))?; + } + Ok(sender) +} + +fn host_resolver_worker(receiver: Arc>>) { + loop { + let request = { + let receiver = receiver.lock().unwrap(); + let Ok(request) = receiver.recv() else { + return; + }; + request + }; + #[cfg(test)] + if let Some(gate) = request.gate.as_ref() { + gate.enter_and_wait(); + } + let result = if request.deadline <= Instant::now() { + Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "CDP address resolution deadline expired", + )) + } else { + resolve_host_addresses_until(&request.host, request.port, request.deadline) + }; + let _ = request.response.send(result); + } +} + +fn resolve_host_addresses_until( + host: &str, + port: u16, + deadline: Instant, +) -> std::io::Result> { + #[cfg(test)] + { + let test_command = TEST_HOST_RESOLVER_COMMAND.lock().unwrap().clone(); + if test_command.is_none() { + return (host, port).to_socket_addrs().map(Iterator::collect); + } + } + + let reaper = reserve_resolver_child_reaper()?; + let mut command = host_resolver_command(host, port)?; + command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()); + let mut child = cmux_tui_process::spawn_until(&mut command, deadline)?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| std::io::Error::other("host resolver helper stdout was unavailable"))?; + let (output_sender, output_receiver) = sync_channel(1); + // Resolver workers bound the number of concurrent helpers and readers. + // Timeout cleanup kills the owned helper, which closes this pipe even + // when the helper was blocked while producing output. + let output_reader = + std::thread::Builder::new().name("cmux-tui-cdp-resolver-output".into()).spawn(move || { + let mut output = Vec::new(); + let mut buffer = [0_u8; 8 * 1024]; + let limit = usize::try_from(CDP_RESOLVER_OUTPUT_LIMIT).unwrap_or(usize::MAX); + let result = loop { + match stdout.read(&mut buffer) { + Ok(0) => break Ok(output), + Ok(count) => { + let retained = + limit.saturating_add(1).saturating_sub(output.len()).min(count); + output.extend_from_slice(&buffer[..retained]); + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => break Err(error), + } + }; + let _ = output_sender.send(result); + }); + if let Err(error) = output_reader { + handoff_resolver_child(child, reaper); + return Err(error); + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + handoff_resolver_child(child, reaper); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "CDP address resolution deadline expired", + )); + }; + let output = match output_receiver.recv_timeout(remaining) { + Ok(Ok(output)) => output, + Ok(Err(error)) => { + handoff_resolver_child(child, reaper); + return Err(error); + } + Err(RecvTimeoutError::Timeout) => { + handoff_resolver_child(child, reaper); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "CDP address resolution deadline expired", + )); + } + Err(RecvTimeoutError::Disconnected) => { + handoff_resolver_child(child, reaper); + return Err(std::io::Error::other("host resolver output reader stopped")); + } + }; + if Instant::now() >= deadline { + handoff_resolver_child(child, reaper); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "CDP address resolution deadline expired", + )); + } + // The resolver helper owns its stdout for its full lifetime. EOF is its + // completion signal, so wait can reap without polling process state. + let status = child.wait()?; + drop(reaper); + if !status.success() { + return Err(std::io::Error::other(format!("host resolver helper exited with {status}"))); + } + if output.len() as u64 > CDP_RESOLVER_OUTPUT_LIMIT { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "host resolver helper output exceeded its limit", + )); + } + let addresses = String::from_utf8_lossy(&output) + .lines() + .filter_map(|line| line.parse::().ok()) + .filter(|address| address.port() == port) + .collect::>(); + if addresses.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "host resolver helper returned no addresses", + )); + } + Ok(addresses) +} + +fn host_resolver_command(host: &str, port: u16) -> std::io::Result { + #[cfg(test)] + if let Some(command) = TEST_HOST_RESOLVER_COMMAND.lock().unwrap().clone() { + return Ok(command(host, port)); + } + let mut command = Command::new(std::env::current_exe()?); + command.args(["__resolve-host", host, &port.to_string()]); + Ok(command) +} + +fn resolver_child_reaper() -> std::io::Result<()> { + let mut slot = CDP_RESOLVER_CHILD_REAPER.get_or_init(|| Mutex::new(None)).lock().unwrap(); + if slot.is_none() { + let (sender, receiver) = channel(); + let active = Arc::new(AtomicUsize::new(0)); + let degraded = Arc::new(AtomicBool::new(false)); + let worker_degraded = degraded.clone(); + let worker = std::thread::Builder::new() + .name("cmux-tui-cdp-resolver-reaper".into()) + .spawn(move || run_resolver_child_reaper(receiver, worker_degraded))?; + *slot = Some(ResolverChildReaper { sender, active, degraded, _worker: worker }); + } + Ok(()) +} + +fn reserve_resolver_child_reaper() -> std::io::Result { + resolver_child_reaper()?; + let slot = CDP_RESOLVER_CHILD_REAPER.get().unwrap().lock().unwrap(); + let reaper = slot.as_ref().expect("resolver child reaper initialized"); + if reaper.degraded.load(Ordering::Acquire) { + return Err(std::io::Error::other( + "host resolver child cleanup is temporarily unavailable", + )); + } + reaper + .active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < CDP_RESOLVER_CHILD_CAPACITY).then_some(active + 1) + }) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "host resolver child capacity exhausted", + ) + })?; + if reaper.degraded.load(Ordering::Acquire) { + reaper.active.fetch_sub(1, Ordering::AcqRel); + return Err(std::io::Error::other( + "host resolver child cleanup is temporarily unavailable", + )); + } + Ok(ResolverChildReaperLease { sender: reaper.sender.clone(), active: reaper.active.clone() }) +} + +fn handoff_resolver_child(mut child: Child, lease: ResolverChildReaperLease) { + let _ = child.kill(); + let sender = lease.sender.clone(); + let now = Instant::now(); + let request = ResolverChildReapRequest { + child, + _lease: lease, + next_attempt: now, + retry_delay: CDP_RESOLVER_POLL_INTERVAL, + retry_deadline: now + CDP_RESOLVER_REAP_RETRY_WINDOW, + }; + if let Err(error) = sender.send(request) { + let mut request = error.0; + let _ = request.child.kill(); + let _ = request.child.wait(); + } +} + +fn run_resolver_child_reaper( + receiver: Receiver, + degraded: Arc, +) { + let mut pending = Vec::new(); + let mut disconnected = false; + loop { + if pending.is_empty() { + if disconnected { + return; + } + match receiver.recv() { + Ok(request) => pending.push(request), + Err(_) => return, + } + } else { + let now = Instant::now(); + let wait = pending + .iter() + .map(|request| request.next_attempt.saturating_duration_since(now)) + .min() + .unwrap_or_default(); + if disconnected { + std::thread::sleep(wait); + } else { + match receiver.recv_timeout(wait) { + Ok(request) => pending.push(request), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + disconnected = true; + } + } + } + } + while let Ok(request) = receiver.try_recv() { + pending.push(request); + } + let now = Instant::now(); + let mut completed = Vec::new(); + pending.retain_mut(|request| { + if request.next_attempt > now { + return true; + } + let _ = request.child.kill(); + match resolver_child_try_wait(request) { + Ok(Some(_)) => { + completed.push(request.child.id()); + false + } + Err(error) if resolver_child_wait_lost_ownership(&error) => { + completed.push(request.child.id()); + false + } + Ok(None) | Err(_) => { + if now >= request.retry_deadline { + degraded.store(true, Ordering::Release); + } + request.next_attempt = now + request.retry_delay; + request.retry_delay = next_resolver_reap_delay(request.retry_delay); + true + } + } + }); + #[cfg(test)] + for child in completed { + publish_resolver_reaper_test_event(ResolverReaperTestEvent::Complete(child)); + } + if pending.is_empty() { + degraded.store(false, Ordering::Release); + } + } +} + +fn next_resolver_reap_delay(current: Duration) -> Duration { + current.saturating_mul(2).min(CDP_RESOLVER_REAP_MAX_BACKOFF) +} + +fn resolver_child_wait_lost_ownership(error: &std::io::Error) -> bool { + #[cfg(unix)] + { + error.raw_os_error() == Some(libc::ECHILD) + } + #[cfg(not(unix))] + { + let _ = error; + false + } +} + +fn resolver_child_try_wait( + request: &mut ResolverChildReapRequest, +) -> std::io::Result> { + #[cfg(test)] + { + publish_resolver_reaper_test_event(ResolverReaperTestEvent::Attempt(request.child.id())); + if let Some(kind) = *TEST_RESOLVER_REAPER_WAIT_ERROR.lock().unwrap() { + return Err(std::io::Error::from(kind)); + } + } + request.child.try_wait() +} + +#[cfg(test)] +fn publish_resolver_reaper_test_event(event: ResolverReaperTestEvent) { + if let Some(sender) = TEST_RESOLVER_REAPER_EVENT_SENDER.lock().unwrap().as_ref() { + let _ = sender.send(event); + } +} + +#[cfg(test)] +struct ResolverReaperTestObserver; + +#[cfg(test)] +impl ResolverReaperTestObserver { + fn install() -> (Self, Receiver) { + let (sender, receiver) = channel(); + let previous = TEST_RESOLVER_REAPER_EVENT_SENDER.lock().unwrap().replace(sender); + assert!(previous.is_none(), "resolver reaper test observer was already installed"); + (Self, receiver) + } +} + +#[cfg(test)] +impl Drop for ResolverReaperTestObserver { + fn drop(&mut self) { + TEST_RESOLVER_REAPER_EVENT_SENDER.lock().unwrap().take(); + } +} + +#[cfg(test)] +fn wait_for_resolver_reaper_test_event( + receiver: &Receiver, + expected: ResolverReaperTestEvent, +) { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("resolver reaper did not publish the expected lifecycle event"); + let event = receiver + .recv_timeout(remaining) + .expect("resolver reaper did not publish the expected lifecycle event"); + if event == expected { + return; + } + } +} + +#[doc(hidden)] +pub fn run_host_resolver_helper(args: &[String]) -> anyhow::Result<()> { + let [host, port] = args else { + anyhow::bail!("host resolver helper requires a host and port"); + }; + let port = port.parse::()?; + let addresses = (host.as_str(), port).to_socket_addrs()?.take(257).collect::>(); + if addresses.is_empty() { + anyhow::bail!("host resolver returned no addresses"); + } + if addresses.len() > 256 { + anyhow::bail!("host resolver returned too many addresses"); + } + let stdout = std::io::stdout(); + let mut output = std::io::BufWriter::new(stdout.lock()); + for address in addresses { + writeln!(output, "{address}")?; + } + output.flush()?; + Ok(()) +} /// Monotonic browser-frame generation shared by CDP ingress and one surface. /// @@ -273,6 +875,7 @@ pub struct CdpClient { struct Inner { outbound: Sender, + transport_close: Mutex, pending: Mutex>, events: Arc, frame_epochs: Mutex>, @@ -280,8 +883,10 @@ struct Inner { next_screencast_capture_request_id: AtomicU64, closed: AtomicBool, timeout: Duration, + web_socket_url: Arc, + peer_addr: SocketAddr, #[cfg(test)] - reader_stopped: Arc, + reader_stopped: Arc, } struct PendingCall { @@ -525,50 +1130,251 @@ impl Drop for Inner { fn drop(&mut self) { self.events.close("CDP client dropped"); } -} +} + +enum Outbound { + Message(String), + Flush(Sender<()>), + Close(SyncSender<()>), +} + +enum TransportClose { + Open, + Closing(Receiver<()>), + Closed, +} + +struct DeadlineTcpStream { + stream: TcpStream, + deadline: Option, +} + +impl DeadlineTcpStream { + fn new(stream: TcpStream, deadline: Instant) -> Self { + Self { stream, deadline: Some(deadline) } + } + + fn clear_deadline(&mut self) { + self.deadline = None; + } + + fn remaining(&self) -> std::io::Result> { + let Some(deadline) = self.deadline else { + return Ok(None); + }; + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .map(Some) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::TimedOut, "CDP connection deadline expired") + }) + } + + fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + self.stream.set_nodelay(nodelay) + } + + fn set_read_timeout(&self, timeout: Option) -> std::io::Result<()> { + self.stream.set_read_timeout(timeout) + } + + fn set_write_timeout(&self, timeout: Option) -> std::io::Result<()> { + self.stream.set_write_timeout(timeout) + } +} + +impl Read for DeadlineTcpStream { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if let Some(remaining) = self.remaining()? { + self.stream.set_read_timeout(Some(remaining))?; + } + self.stream.read(buffer) + } +} + +impl Write for DeadlineTcpStream { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + if let Some(remaining) = self.remaining()? { + self.stream.set_write_timeout(Some(remaining))?; + } + self.stream.write(buffer) + } + + fn flush(&mut self) -> std::io::Result<()> { + if let Some(remaining) = self.remaining()? { + self.stream.set_write_timeout(Some(remaining))?; + } + self.stream.flush() + } +} + +fn deadline_after(timeout: Duration, expired: &str) -> anyhow::Result { + if timeout.is_zero() { + anyhow::bail!("{expired}"); + } + Instant::now().checked_add(timeout).ok_or_else(|| anyhow::anyhow!("CDP deadline overflow")) +} + +fn remaining_until(deadline: Instant, expired: &str) -> anyhow::Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| anyhow::anyhow!("{expired}")) +} + +fn submit_resolve_request(request: ResolveRequest) -> anyhow::Result<()> { + let registry = CDP_RESOLVER.get_or_init(|| Mutex::new(None)); + let resolver = { + let mut state = registry.lock().unwrap(); + if state.is_none() { + *state = Some(Arc::new(CdpResolver::spawn()?)); + } + state.as_ref().unwrap().clone() + }; + let result = resolver.submit(request); + if result.is_err() && resolver.initialization_failed() { + let mut state = registry.lock().unwrap(); + if state.as_ref().is_some_and(|current| Arc::ptr_eq(current, &resolver)) { + *state = None; + } + } + result +} + +fn resolve_socket_addr_until( + host: &str, + port: u16, + deadline: Instant, +) -> anyhow::Result { + if let Ok(ip) = host.parse::() { + remaining_until(deadline, "CDP address resolution deadline expired")?; + return Ok(SocketAddr::new(ip, port)); + } + + let (response, receiver) = sync_channel(1); + submit_resolve_request(ResolveRequest { + host: host.to_string(), + port, + deadline, + response, + #[cfg(test)] + gate: NEXT_RESOLVE_GATE.lock().unwrap().take(), + })?; + let remaining = remaining_until(deadline, "CDP address resolution deadline expired")?; + let addresses = match receiver.recv_timeout(remaining) { + Ok(result) => result?, + Err(RecvTimeoutError::Timeout) => { + anyhow::bail!("CDP address resolution deadline expired") + } + Err(RecvTimeoutError::Disconnected) => { + anyhow::bail!("CDP resolver worker stopped") + } + }; + remaining_until(deadline, "CDP address resolution deadline expired")?; + addresses + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("no socket address for {host}:{port}")) +} + +impl CdpClient { + pub fn connect(web_socket_url: &str, events: SyncSender) -> anyhow::Result { + Self::connect_with_bearer(web_socket_url, None, events) + } + + pub fn connect_with_timeout( + web_socket_url: &str, + events: SyncSender, + timeout: Duration, + ) -> anyhow::Result { + Self::connect_with_bearer_and_timeout(web_socket_url, None, events, timeout) + } + + pub fn connect_with_bearer( + web_socket_url: &str, + bearer_token: Option<&str>, + events: SyncSender, + ) -> anyhow::Result { + Self::connect_with_bearer_and_timeout( + web_socket_url, + bearer_token, + events, + Duration::from_secs(5), + ) + } + + fn connect_with_bearer_and_timeout( + web_socket_url: &str, + bearer_token: Option<&str>, + events: SyncSender, + timeout: Duration, + ) -> anyhow::Result { + let deadline = deadline_after(timeout, "CDP connection deadline expired")?; + let endpoint = WsEndpoint::parse(web_socket_url)?; + let addr = resolve_socket_addr_until(&endpoint.host, endpoint.port, deadline)?; + Self::connect_to_addr_until(web_socket_url, bearer_token, addr, events, deadline, timeout) + } -enum Outbound { - Message(String), - Flush(Sender<()>), -} + pub fn reconnect_with_timeout( + &self, + events: SyncSender, + timeout: Duration, + ) -> anyhow::Result { + Self::connect_to_addr_with_timeout( + &self.inner.web_socket_url, + self.inner.peer_addr, + events, + timeout, + ) + } -impl CdpClient { - pub fn connect(web_socket_url: &str, events: SyncSender) -> anyhow::Result { - Self::connect_with_bearer(web_socket_url, None, events) + fn connect_to_addr_with_timeout( + web_socket_url: &str, + addr: SocketAddr, + events: SyncSender, + timeout: Duration, + ) -> anyhow::Result { + let deadline = deadline_after(timeout, "CDP connection deadline expired")?; + Self::connect_to_addr_until(web_socket_url, None, addr, events, deadline, timeout) } - pub fn connect_with_bearer( + fn connect_to_addr_until( web_socket_url: &str, bearer_token: Option<&str>, + addr: SocketAddr, events: SyncSender, + deadline: Instant, + timeout: Duration, ) -> anyhow::Result { - let endpoint = WsEndpoint::parse(web_socket_url)?; - let mut addrs = (endpoint.host.as_str(), endpoint.port).to_socket_addrs()?; - let addr = addrs.next().ok_or_else(|| { - anyhow::anyhow!("no socket address for {}:{}", endpoint.host, endpoint.port) - })?; - let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?; + let remaining = remaining_until(deadline, "CDP connection deadline expired")?; + let stream = cmux_tui_process::tcp::connect_stream_timeout(&addr, remaining)?; + let stream = DeadlineTcpStream::new(stream, deadline); stream.set_nodelay(true)?; - stream.set_read_timeout(Some(Duration::from_secs(5)))?; - stream.set_write_timeout(Some(Duration::from_secs(5)))?; let mut request = web_socket_url.into_client_request()?; if let Some(token) = bearer_token { let value = HeaderValue::from_str(&format!("Bearer {token}")) .map_err(|error| anyhow::anyhow!("invalid CDP bearer token: {error}"))?; request.headers_mut().insert(AUTHORIZATION, value); } - let (ws, _) = client(request, stream)?; + let (mut ws, _) = client(request, stream)?; + ws.get_mut().clear_deadline(); // The reader thread owns the socket and drains queued outbound // writes before each read poll. A message enqueued just after a // read starts can wait for this window, but writers never contend // on the socket itself. ws.get_ref().set_read_timeout(Some(Duration::from_millis(20)))?; - ws.get_ref().set_write_timeout(Some(Duration::from_secs(5)))?; + anyhow::ensure!( + deadline.checked_duration_since(Instant::now()).is_some(), + "CDP connection deadline expired" + ); + ws.get_ref().set_write_timeout(Some(timeout))?; let (outbound_tx, outbound_rx) = channel(); let event_queue = Arc::new(EventQueue::new()); let client = CdpClient { inner: Arc::new(Inner { outbound: outbound_tx, + transport_close: Mutex::new(TransportClose::Open), pending: Mutex::new(HashMap::new()), events: event_queue, frame_epochs: Mutex::new(HashMap::new()), @@ -576,8 +1382,10 @@ impl CdpClient { next_screencast_capture_request_id: AtomicU64::new(1), closed: AtomicBool::new(false), timeout: Duration::from_secs(30), + web_socket_url: Arc::from(web_socket_url), + peer_addr: addr, #[cfg(test)] - reader_stopped: Arc::new(AtomicBool::new(false)), + reader_stopped: Arc::new(ReaderStoppedSignal::default()), }), }; client.spawn_reader(ws, outbound_rx, events)?; @@ -586,7 +1394,7 @@ impl CdpClient { fn spawn_reader( &self, - ws: WebSocket, + ws: WebSocket, outbound: Receiver, event_output: SyncSender, ) -> anyhow::Result<()> { @@ -596,7 +1404,7 @@ impl CdpClient { std::thread::Builder::new().name("cmux-tui-cdp-reader".into()).spawn(move || { reader_loop(&weak, ws, &outbound, &event_output); #[cfg(test)] - reader_stopped.store(true, Ordering::Release); + reader_stopped.mark(); })?; Ok(()) } @@ -609,7 +1417,17 @@ impl CdpClient { params: Value, session_id: Option<&str>, ) -> anyhow::Result { - self.call_inner(method, params, session_id, None, None) + self.call_inner(method, params, session_id, None, None, None) + } + + pub fn call_with_timeout( + &self, + method: &str, + params: Value, + session_id: Option<&str>, + timeout: Duration, + ) -> anyhow::Result { + self.call_inner(method, params, session_id, None, None, Some(timeout)) } fn call_before( @@ -619,7 +1437,7 @@ impl CdpClient { session_id: Option<&str>, deadline: Instant, ) -> anyhow::Result { - self.call_inner(method, params, session_id, None, Some(deadline)) + self.call_inner(method, params, session_id, None, Some(deadline), None) } fn call_with_frame_barrier( @@ -629,7 +1447,7 @@ impl CdpClient { session_id: &str, frame_barrier: Arc, ) -> anyhow::Result { - self.call_inner(method, params, Some(session_id), Some(frame_barrier), None) + self.call_inner(method, params, Some(session_id), Some(frame_barrier), None, None) } fn call_with_frame_barrier_before( @@ -640,7 +1458,7 @@ impl CdpClient { frame_barrier: Arc, deadline: Instant, ) -> anyhow::Result { - self.call_inner(method, params, Some(session_id), Some(frame_barrier), Some(deadline)) + self.call_inner(method, params, Some(session_id), Some(frame_barrier), Some(deadline), None) } fn call_inner( @@ -650,15 +1468,17 @@ impl CdpClient { session_id: Option<&str>, frame_barrier: Option>, deadline: Option, + timeout_override: Option, ) -> anyhow::Result { + let timeout_limit = timeout_override.unwrap_or(self.inner.timeout); let timeout = match deadline { Some(deadline) => { let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { anyhow::bail!("CDP call {method} timed out"); }; - self.inner.timeout.min(remaining) + timeout_limit.min(remaining) } - None => self.inner.timeout, + None => timeout_limit, }; if timeout.is_zero() { anyhow::bail!("CDP call {method} timed out"); @@ -747,7 +1567,54 @@ impl CdpClient { } pub fn close_target(&self, target_id: &str) -> anyhow::Result<()> { - self.call("Target.closeTarget", json!({ "targetId": target_id }), None).map(|_| ()) + let result = self.call("Target.closeTarget", json!({ "targetId": target_id }), None)?; + require_target_close_success(&result) + } + + pub fn close_target_with_timeout( + &self, + target_id: &str, + timeout: Duration, + ) -> anyhow::Result<()> { + let result = self.call_with_timeout( + "Target.closeTarget", + json!({ "targetId": target_id }), + None, + timeout, + )?; + require_target_close_success(&result) + } + + pub fn target_exists_with_timeout( + &self, + target_id: &str, + timeout: Duration, + ) -> anyhow::Result { + Ok(self.target_ids_with_timeout(timeout)?.iter().any(|candidate| candidate == target_id)) + } + + pub fn target_ids_with_timeout(&self, timeout: Duration) -> anyhow::Result> { + let result = self.call_with_timeout("Target.getTargets", json!({}), None, timeout)?; + let targets = result + .get("targetInfos") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("Target.getTargets response missing targetInfos"))?; + targets + .iter() + .enumerate() + .map(|(index, target)| { + target + .get("targetId") + .and_then(Value::as_str) + .filter(|target_id| !target_id.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + anyhow::anyhow!( + "Target.getTargets targetInfos[{index}] missing a valid targetId" + ) + }) + }) + .collect() } pub fn close_target_detached(&self, target_id: &str) -> anyhow::Result<()> { @@ -779,14 +1646,54 @@ impl CdpClient { if self.inner.closed.load(Ordering::Acquire) { anyhow::bail!("CDP connection is closed"); } + let close = self.inner.transport_close.lock().unwrap(); + if !matches!(*close, TransportClose::Open) { + anyhow::bail!("CDP connection is closing"); + } let (tx, rx) = channel(); self.inner .outbound .send(Outbound::Flush(tx)) .map_err(|_| anyhow::anyhow!("CDP connection is closed"))?; + drop(close); rx.recv_timeout(timeout).map_err(|_| anyhow::anyhow!("timed out flushing CDP commands")) } + /// Close the WebSocket transport once and retain its completion receipt + /// across deadline expiry so a retry cannot publish a second close owner. + pub fn close_until(&self, deadline: Instant) -> bool { + let mut close = self.inner.transport_close.lock().unwrap(); + loop { + match &mut *close { + TransportClose::Open => { + if self.inner.closed.load(Ordering::Acquire) { + *close = TransportClose::Closed; + return true; + } + let (receipt, receiver) = sync_channel(1); + if self.inner.outbound.send(Outbound::Close(receipt)).is_err() { + *close = TransportClose::Closed; + return true; + } + *close = TransportClose::Closing(receiver); + } + TransportClose::Closing(receiver) => { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + match receiver.recv_timeout(remaining) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => { + *close = TransportClose::Closed; + return true; + } + Err(RecvTimeoutError::Timeout) => return false, + } + } + TransportClose::Closed => return true, + } + } + } + pub fn page_enable(&self, session_id: &str) -> anyhow::Result<()> { self.call("Page.enable", json!({}), Some(session_id)).map(|_| ()) } @@ -1373,6 +2280,10 @@ impl CdpClient { if self.inner.closed.load(Ordering::Acquire) { anyhow::bail!("CDP connection is closed"); } + let close = self.inner.transport_close.lock().unwrap(); + if !matches!(*close, TransportClose::Open) { + anyhow::bail!("CDP connection is closing"); + } let text = serde_json::to_string(value)?; if cdp_debug() { eprintln!("cdp-> {text}"); @@ -1381,10 +2292,19 @@ impl CdpClient { .outbound .send(Outbound::Message(text)) .map_err(|_| anyhow::anyhow!("CDP connection is closed"))?; + drop(close); Ok(()) } } +fn require_target_close_success(result: &Value) -> anyhow::Result<()> { + match result.get("success").and_then(Value::as_bool) { + Some(true) => Ok(()), + Some(false) => anyhow::bail!("Target.closeTarget reported success=false"), + None => anyhow::bail!("Target.closeTarget response missing success"), + } +} + pub fn resolve_browser_ws_url(input: &str) -> anyhow::Result { let trimmed = input.trim(); if trimmed.starts_with("ws://") { @@ -1403,7 +2323,7 @@ pub fn discover_browser_ws_url(ports: &[u16]) -> Option { fn reader_loop( weak: &Weak, - mut ws: WebSocket, + mut ws: WebSocket, outbound: &Receiver, event_output: &SyncSender, ) { @@ -1416,9 +2336,19 @@ fn reader_loop( if inner.closed.load(Ordering::Acquire) { break; } - if let Err(err) = drain_outbound(&mut ws, outbound) { - close_inner(&inner, &format!("CDP socket error: {err}")); - break; + match drain_outbound(&mut ws, outbound) { + Ok(OutboundDrain::Continue) => {} + Ok(OutboundDrain::Close(receipt)) => { + let _ = ws.close(None); + let _ = ws.flush(); + close_inner(&inner, "CDP client shut down"); + let _ = receipt.send(()); + break; + } + Err(err) => { + close_inner(&inner, &format!("CDP socket error: {err}")); + break; + } } let message = ws.read(); match message { @@ -1452,20 +2382,28 @@ fn reader_loop( } fn drain_outbound( - ws: &mut WebSocket, + ws: &mut WebSocket, outbound: &Receiver, -) -> anyhow::Result<()> { +) -> anyhow::Result { loop { match outbound.try_recv() { Ok(Outbound::Message(text)) => ws.send(Message::Text(text.into()))?, Ok(Outbound::Flush(done)) => { let _ = done.send(()); } - Err(TryRecvError::Empty | TryRecvError::Disconnected) => return Ok(()), + Ok(Outbound::Close(receipt)) => return Ok(OutboundDrain::Close(receipt)), + Err(TryRecvError::Empty | TryRecvError::Disconnected) => { + return Ok(OutboundDrain::Continue); + } } } } +enum OutboundDrain { + Continue, + Close(SyncSender<()>), +} + fn handle_text(inner: &Arc, text: &str) { if cdp_debug() { eprintln!("cdp<- {}", &text[..text.len().min(300)]); @@ -1918,19 +2856,23 @@ impl HttpEndpoint { } fn fetch_json_version(host: &str, port: u16) -> anyhow::Result { - let mut addrs = (host, port).to_socket_addrs()?; - let addr = - addrs.next().ok_or_else(|| anyhow::anyhow!("no socket address for {host}:{port}"))?; - let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250))?; - stream.set_nodelay(true)?; - stream.set_read_timeout(Some(Duration::from_millis(500)))?; - stream.set_write_timeout(Some(Duration::from_millis(500)))?; - write!( - stream, - "GET /json/version HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n" + let deadline = deadline_after(CDP_DISCOVERY_TIMEOUT, "CDP discovery deadline exceeded")?; + let addr = resolve_socket_addr_until(host, port, deadline)?; + let remaining = remaining_until(deadline, "CDP discovery deadline exceeded")?; + let mut stream = cmux_tui_process::tcp::connect_stream_timeout( + &addr, + remaining.min(Duration::from_millis(250)), )?; + stream.set_nodelay(true)?; + let request = + format!("GET /json/version HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); + let remaining = remaining_until(deadline, "CDP discovery deadline exceeded")?; + stream.set_write_timeout(Some(remaining.min(Duration::from_millis(500))))?; + stream.write_all(request.as_bytes())?; + let remaining = remaining_until(deadline, "CDP discovery deadline exceeded")?; + stream.set_write_timeout(Some(remaining.min(Duration::from_millis(500))))?; stream.flush()?; - let response = read_http_response(&mut stream)?; + let response = read_http_response_until(&mut stream, 64 * 1024, deadline)?; let body = response .split_once("\r\n\r\n") .map(|(_, body)| body) @@ -1943,22 +2885,30 @@ fn fetch_json_version(host: &str, port: u16) -> anyhow::Result { .ok_or_else(|| anyhow::anyhow!("/json/version missing webSocketDebuggerUrl")) } +#[cfg(test)] fn read_http_response(stream: &mut TcpStream) -> anyhow::Result { read_http_response_with_limits(stream, 64 * 1024, Duration::from_secs(2)) } +#[cfg(test)] fn read_http_response_with_limits( stream: &mut TcpStream, max_bytes: usize, timeout: Duration, ) -> anyhow::Result { - let deadline = Instant::now() + timeout; + let deadline = deadline_after(timeout, "CDP discovery deadline exceeded")?; + read_http_response_until(stream, max_bytes, deadline) +} + +fn read_http_response_until( + stream: &mut TcpStream, + max_bytes: usize, + deadline: Instant, +) -> anyhow::Result { let mut bytes = Vec::new(); let mut buf = [0u8; 1024]; loop { - let remaining = deadline - .checked_duration_since(Instant::now()) - .ok_or_else(|| anyhow::anyhow!("CDP discovery deadline exceeded"))?; + let remaining = remaining_until(deadline, "CDP discovery deadline exceeded")?; stream.set_read_timeout(Some(remaining.min(Duration::from_millis(500))))?; match stream.read(&mut buf) { Ok(0) => break, @@ -2035,10 +2985,10 @@ impl WsEndpoint { #[cfg(test)] mod tests { - use std::io::Write; + use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::mpsc::sync_channel; - use std::sync::{Arc, Barrier}; + use std::sync::{Arc, Barrier, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -2046,11 +2996,88 @@ mod tests { use super::*; + static RESOLVE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn scaled_test_duration(duration: Duration) -> Duration { + let scale = std::env::var("CMUX_TEST_TIMEOUT_SCALE") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|scale| *scale > 0) + .unwrap_or(1); + duration.saturating_mul(scale) + } + + struct TestHostResolverCommandGuard(Option); + + impl TestHostResolverCommandGuard { + fn install(command: TestHostResolverCommand) -> Self { + let previous = TEST_HOST_RESOLVER_COMMAND.lock().unwrap().replace(command); + Self(previous) + } + } + + impl Drop for TestHostResolverCommandGuard { + fn drop(&mut self) { + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = self.0.take(); + } + } + + fn warm_resolver() { + let _command = TestHostResolverCommandGuard::install(Arc::new(resolver_fixture_command)); + resolve_socket_addr_until( + "cmux-warm-resolver.invalid", + 0, + Instant::now() + Duration::from_secs(5), + ) + .expect("initialize test resolver"); + } + + fn resolver_fixture_command(host: &str, port: u16) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "client::tests::host_resolver_process_fixture", + "--ignored", + "--nocapture", + ]) + .env( + "CMUX_TUI_TEST_HOST_RESOLVER_MODE", + if host.starts_with("cmux-blocked-") { + "blocked" + } else if host.starts_with("cmux-oversized-") { + "oversized" + } else { + "success" + }, + ) + .env("CMUX_TUI_TEST_HOST_RESOLVER_PORT", port.to_string()); + command + } + + #[test] + #[ignore] + fn host_resolver_process_fixture() { + if std::env::var("CMUX_TUI_TEST_HOST_RESOLVER_MODE").as_deref() == Ok("blocked") { + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).unwrap(); + return; + } + if std::env::var("CMUX_TUI_TEST_HOST_RESOLVER_MODE").as_deref() == Ok("oversized") { + let output = vec![b'x'; usize::try_from(CDP_RESOLVER_OUTPUT_LIMIT).unwrap() + 1]; + std::io::stdout().write_all(&output).unwrap(); + return; + } + let port = std::env::var("CMUX_TUI_TEST_HOST_RESOLVER_PORT").unwrap(); + println!("127.0.0.1:{port}"); + } + fn test_inner() -> (Arc, Receiver) { let (outbound, outbound_rx) = channel(); ( Arc::new(Inner { outbound, + transport_close: Mutex::new(TransportClose::Open), pending: Mutex::new(HashMap::new()), events: Arc::new(EventQueue::new()), frame_epochs: Mutex::new(HashMap::new()), @@ -2058,7 +3085,9 @@ mod tests { next_screencast_capture_request_id: AtomicU64::new(1), closed: AtomicBool::new(false), timeout: Duration::from_secs(1), - reader_stopped: Arc::new(AtomicBool::new(false)), + web_socket_url: Arc::from("ws://127.0.0.1:1/devtools/browser/test"), + peer_addr: "127.0.0.1:1".parse().unwrap(), + reader_stopped: Arc::new(ReaderStoppedSignal::default()), }), outbound_rx, ) @@ -3466,6 +4495,406 @@ mod tests { server.join().unwrap(); } + #[test] + fn websocket_handshake_uses_one_absolute_deadline() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (release_sender, release_receiver) = channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).unwrap_or(0) == 0 { + return; + } + request.push(byte[0]); + } + stream.write_all(b"HTTP/1.1 400").unwrap(); + release_receiver.recv().unwrap(); + }); + let (events, _receiver) = sync_channel(1); + + let started = Instant::now(); + let error = match CdpClient::connect_to_addr_with_timeout( + &format!("ws://{addr}/devtools/browser/fake"), + addr, + events, + Duration::from_millis(50), + ) { + Ok(_) => panic!("invalid WebSocket handshake unexpectedly succeeded"), + Err(error) => error, + }; + let elapsed = started.elapsed(); + release_sender.send(()).unwrap(); + server.join().unwrap(); + + assert!(!error.to_string().is_empty()); + assert!( + elapsed < Duration::from_millis(200), + "WebSocket handshake exceeded its absolute deadline: {elapsed:?}" + ); + } + + #[test] + fn websocket_resolution_uses_the_connection_deadline() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + warm_resolver(); + let gate = Arc::new(ResolverTestGate::default()); + *NEXT_RESOLVE_GATE.lock().unwrap() = Some(gate.clone()); + let (events, _receiver) = sync_channel(1); + + let started = Instant::now(); + let result = CdpClient::connect_with_timeout( + "ws://localhost:0/devtools/browser/fake", + events, + Duration::from_millis(50), + ); + let elapsed = started.elapsed(); + gate.wait_until_entered(); + gate.release(); + + assert!(result.is_err(), "invalid local endpoint unexpectedly connected"); + assert!( + elapsed < Duration::from_millis(200), + "hostname resolution exceeded the connection deadline: {elapsed:?}" + ); + } + + #[test] + fn reconnect_uses_the_cached_peer_without_resolving_again() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + warm_resolver(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + for _ in 0..2 { + let (stream, _) = listener.accept().unwrap(); + let mut ws = accept(stream).unwrap(); + ws.close(None).unwrap(); + } + 2 + }); + let command = TestHostResolverCommandGuard::install(Arc::new(resolver_fixture_command)); + let (initial_events, _initial_receiver) = sync_channel(1); + let client = CdpClient::connect( + &format!("ws://cmux-cached-peer.invalid:{port}/devtools/browser/fake"), + initial_events, + ) + .unwrap(); + drop(command); + assert!( + client.inner.reader_stopped.wait(Duration::from_secs(1)), + "initial CDP reader did not stop" + ); + + let unused_gate = Arc::new(ResolverTestGate::default()); + *NEXT_RESOLVE_GATE.lock().unwrap() = Some(unused_gate.clone()); + let (reconnect_events, _reconnect_receiver) = sync_channel(1); + let reconnect = client.reconnect_with_timeout(reconnect_events, Duration::from_millis(100)); + let resolver_was_unused = NEXT_RESOLVE_GATE.lock().unwrap().take().is_some(); + unused_gate.release(); + + let reconnected = reconnect.is_ok(); + drop(reconnect); + drop(client); + let connections = server.join().unwrap(); + assert!(reconnected, "cached-peer reconnect did not complete"); + assert_eq!(connections, 2, "cached-peer reconnect did not reach the server"); + assert!(resolver_was_unused, "reconnect invoked the hostname resolver"); + } + + #[test] + fn resolver_initialization_obeys_the_first_request_deadline() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + *CDP_RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap() = None; + let gate = Arc::new(ResolverTestGate::default()); + *NEXT_RESOLVER_INIT_GATE.lock().unwrap() = Some(gate.clone()); + + let started = Instant::now(); + let result = + resolve_socket_addr_until("localhost", 1, Instant::now() + Duration::from_millis(50)); + let elapsed = started.elapsed(); + gate.wait_until_entered(); + gate.release(); + CDP_RESOLVER.get().unwrap().lock().unwrap().as_ref().unwrap().wait_until_initialized(); + + assert!(result.is_err(), "expired first resolution unexpectedly completed"); + assert!( + elapsed < Duration::from_millis(200), + "resolver initialization escaped the first request deadline: {elapsed:?}" + ); + } + + #[test] + fn expired_resolution_does_not_block_the_next_request() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + warm_resolver(); + let _command = TestHostResolverCommandGuard::install(Arc::new(resolver_fixture_command)); + let gate = Arc::new(ResolverTestGate::default()); + *NEXT_RESOLVE_GATE.lock().unwrap() = Some(gate.clone()); + + let first = resolve_socket_addr_until( + "cmux-expired-first.invalid", + 1, + Instant::now() + scaled_test_duration(Duration::from_millis(50)), + ); + let started = Instant::now(); + let second = resolve_socket_addr_until( + "cmux-expired-second.invalid", + 2, + Instant::now() + scaled_test_duration(Duration::from_millis(100)), + ); + let elapsed = started.elapsed(); + + gate.wait_until_entered(); + gate.release(); + + assert!(first.is_err(), "injected slow resolution unexpectedly completed"); + assert!(second.is_ok(), "expired DNS work blocked the next request: {second:?}"); + assert!( + elapsed < scaled_test_duration(Duration::from_millis(200)), + "the next DNS request waited behind expired work: {elapsed:?}" + ); + } + + #[test] + fn local_network_endpoint_uses_the_host_resolver() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + let called = Arc::new(AtomicBool::new(false)); + let called_from_command = called.clone(); + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = Some(Arc::new(move |host, port| { + called_from_command.store(true, Ordering::Release); + assert_eq!(host, "cmux-host-resolver.invalid"); + resolver_fixture_command(host, port) + })); + + let result = resolve_socket_addr_until( + "cmux-host-resolver.invalid", + 9222, + Instant::now() + Duration::from_secs(2), + ); + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = None; + + assert_eq!(result.unwrap(), SocketAddr::from(([127, 0, 0, 1], 9222))); + assert!(called.load(Ordering::Acquire), "host resolver was bypassed"); + } + + #[test] + fn resolver_drains_output_before_waiting_for_helper_exit() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + resolver_child_reaper().unwrap(); + let active = { + let slot = CDP_RESOLVER_CHILD_REAPER.get().unwrap().lock().unwrap(); + slot.as_ref().unwrap().active.clone() + }; + let baseline = active.load(Ordering::Acquire); + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = Some(Arc::new(resolver_fixture_command)); + + let started = Instant::now(); + let result = resolve_host_addresses_until( + "cmux-oversized-output.invalid", + 9222, + started + Duration::from_secs(1), + ); + let elapsed = started.elapsed(); + + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = None; + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData); + assert!( + elapsed < Duration::from_millis(500), + "resolver helper blocked on its undrained output pipe: {elapsed:?}" + ); + assert_eq!(active.load(Ordering::Acquire), baseline); + } + + #[cfg(target_os = "macos")] + #[test] + fn resolver_spawn_obeys_the_absolute_deadline() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = Some(Arc::new(resolver_fixture_command)); + let process_barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (result_sender, result_receiver) = sync_channel(1); + let resolver = thread::spawn(move || { + let started = Instant::now(); + let result = resolve_host_addresses_until( + "cmux-success.invalid", + 9222, + started + Duration::from_millis(40), + ); + result_sender.send((started.elapsed(), result)).unwrap(); + }); + + let before_release = result_receiver.recv_timeout(Duration::from_millis(150)); + drop(process_barrier); + let completed_before_release = before_release.is_ok(); + let (elapsed, result) = before_release.unwrap_or_else(|_| { + result_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("resolver did not finish after the process barrier was released") + }); + resolver.join().unwrap(); + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = None; + + assert!( + completed_before_release, + "resolver helper spawn waited past its absolute deadline" + ); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut); + assert!( + elapsed < Duration::from_millis(150), + "resolver helper spawn exceeded its wall-clock bound: {elapsed:?}" + ); + } + + #[cfg(unix)] + #[test] + fn resolver_reaper_releases_capacity_after_child_ownership_is_lost() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ResolverReaperTestObserver::install(); + resolver_child_reaper().unwrap(); + let active = { + let slot = CDP_RESOLVER_CHILD_REAPER.get().unwrap().lock().unwrap(); + slot.as_ref().unwrap().active.clone() + }; + let baseline = active.load(Ordering::Acquire); + let lease = reserve_resolver_child_reaper().unwrap(); + assert_eq!(active.load(Ordering::Acquire), baseline + 1); + + let mut command = Command::new("/bin/sh"); + command.args(["-c", "exit 0"]); + let mut child = cmux_tui_process::spawn(&mut command).unwrap(); + let pid = libc::pid_t::try_from(child.id()).unwrap(); + let mut status = 0; + // SAFETY: pid identifies this live test child, status points to valid + // writable storage, and this call intentionally consumes wait ownership. + assert_eq!(unsafe { libc::waitpid(pid, &raw mut status, 0) }, pid); + assert_eq!(child.try_wait().unwrap_err().raw_os_error(), Some(libc::ECHILD)); + + handoff_resolver_child(child, lease); + wait_for_resolver_reaper_test_event(&events, ResolverReaperTestEvent::Complete(pid as u32)); + + assert_eq!( + active.load(Ordering::Acquire), + baseline, + "terminal wait errors permanently retained resolver capacity" + ); + } + + #[cfg(unix)] + #[test] + fn resolver_reaper_backs_off_retryable_wait_errors() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ResolverReaperTestObserver::install(); + resolver_child_reaper().unwrap(); + let active = { + let slot = CDP_RESOLVER_CHILD_REAPER.get().unwrap().lock().unwrap(); + slot.as_ref().unwrap().active.clone() + }; + let baseline = active.load(Ordering::Acquire); + let lease = reserve_resolver_child_reaper().unwrap(); + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 30"]); + let child = cmux_tui_process::spawn(&mut command).unwrap(); + let pid = child.id(); + + *TEST_RESOLVER_REAPER_WAIT_ERROR.lock().unwrap() = Some(std::io::ErrorKind::Interrupted); + handoff_resolver_child(child, lease); + wait_for_resolver_reaper_test_event(&events, ResolverReaperTestEvent::Attempt(pid)); + *TEST_RESOLVER_REAPER_WAIT_ERROR.lock().unwrap() = None; + + wait_for_resolver_reaper_test_event(&events, ResolverReaperTestEvent::Complete(pid)); + assert_eq!(active.load(Ordering::Acquire), baseline); + assert_eq!( + next_resolver_reap_delay(CDP_RESOLVER_POLL_INTERVAL), + CDP_RESOLVER_POLL_INTERVAL * 2 + ); + assert_eq!( + next_resolver_reap_delay(CDP_RESOLVER_REAP_MAX_BACKOFF), + CDP_RESOLVER_REAP_MAX_BACKOFF + ); + } + + #[test] + fn resolver_reaper_retains_capacity_after_inconclusive_wait_error() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ResolverReaperTestObserver::install(); + resolver_child_reaper().unwrap(); + let active = { + let slot = CDP_RESOLVER_CHILD_REAPER.get().unwrap().lock().unwrap(); + slot.as_ref().unwrap().active.clone() + }; + let baseline = active.load(Ordering::Acquire); + let lease = reserve_resolver_child_reaper().unwrap(); + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 30"]); + let child = cmux_tui_process::spawn(&mut command).unwrap(); + let pid = child.id(); + + *TEST_RESOLVER_REAPER_WAIT_ERROR.lock().unwrap() = + Some(std::io::ErrorKind::PermissionDenied); + handoff_resolver_child(child, lease); + wait_for_resolver_reaper_test_event(&events, ResolverReaperTestEvent::Attempt(pid)); + let retained = active.load(Ordering::Acquire) == baseline + 1; + *TEST_RESOLVER_REAPER_WAIT_ERROR.lock().unwrap() = None; + + wait_for_resolver_reaper_test_event(&events, ResolverReaperTestEvent::Complete(pid)); + assert_eq!(active.load(Ordering::Acquire), baseline); + assert!(retained, "inconclusive child wait error discarded resolver ownership"); + } + + #[test] + fn timed_out_host_lookups_do_not_strand_resolver_capacity() { + let _guard = RESOLVE_TEST_LOCK.lock().unwrap(); + let (_observer, events) = ResolverReaperTestObserver::install(); + warm_resolver(); + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = Some(Arc::new(resolver_fixture_command)); + + let callers_ready = Arc::new(Barrier::new(CDP_RESOLVER_WORKERS + 1)); + let mut callers = Vec::new(); + for index in 0..CDP_RESOLVER_WORKERS { + let callers_ready = callers_ready.clone(); + callers.push(thread::spawn(move || { + callers_ready.wait(); + resolve_socket_addr_until( + &format!("cmux-blocked-{index}.invalid"), + 9222, + Instant::now() + Duration::from_millis(500), + ) + })); + } + callers_ready.wait(); + for caller in callers { + assert!(caller.join().unwrap().is_err()); + } + let mut completed = std::collections::HashSet::new(); + let deadline = Instant::now() + Duration::from_secs(1); + while completed.len() < CDP_RESOLVER_WORKERS { + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("resolver reaper did not release all timed-out helpers"); + match events + .recv_timeout(remaining) + .expect("resolver reaper did not release all timed-out helpers") + { + ResolverReaperTestEvent::Complete(pid) => { + completed.insert(pid); + } + ResolverReaperTestEvent::Attempt(_) => {} + } + } + + let next = resolve_socket_addr_until( + "cmux-success.invalid", + 9333, + Instant::now() + Duration::from_secs(1), + ); + + *TEST_HOST_RESOLVER_COMMAND.lock().unwrap() = None; + + assert_eq!(next.unwrap(), SocketAddr::from(([127, 0, 0, 1], 9333))); + } + #[test] fn concurrent_calls_complete_while_reader_receives_events() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -3671,6 +5100,45 @@ mod tests { ); } + #[test] + fn malformed_target_listing_never_confirms_absence() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = accept(stream).unwrap(); + let request = ws.read().unwrap(); + let Message::Text(request) = request else { panic!("expected text request") }; + let request: Value = serde_json::from_str(&request).unwrap(); + assert_eq!(request["method"], "Target.getTargets"); + ws.send(Message::Text( + json!({ + "id": request["id"], + "result": { + "targetInfos": [ + {"targetId": "known-target"}, + {"type": "page"}, + {"targetId": 7} + ] + } + }) + .to_string() + .into(), + )) + .unwrap(); + }); + let (event_tx, _event_rx) = sync_channel(1); + let client = + CdpClient::connect(&format!("ws://{addr}/devtools/browser/fake"), event_tx).unwrap(); + + let result = + client.target_exists_with_timeout("missing-target", Duration::from_millis(500)); + + drop(client); + server.join().unwrap(); + assert!(result.is_err(), "malformed target metadata confirmed target absence"); + } + #[test] fn socket_shutdown_disconnects_a_backpressured_event_receiver() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -3692,18 +5160,17 @@ mod tests { CdpClient::connect(&format!("ws://{addr}/devtools/browser/fake"), event_tx).unwrap(); server.join().unwrap(); - let deadline = Instant::now() + Duration::from_millis(500); - while !client.inner.reader_stopped.load(Ordering::Acquire) { - assert!(Instant::now() < deadline, "CDP reader did not stop"); - thread::yield_now(); - } + assert!( + client.inner.reader_stopped.wait(Duration::from_millis(500)), + "CDP reader did not stop" + ); assert!(matches!(event_rx.recv(), Ok(CdpEvent::Other { .. }))); let shutdown = event_rx.recv_timeout(Duration::from_millis(500)); drop(client); assert!( - matches!(shutdown, Err(std::sync::mpsc::RecvTimeoutError::Disconnected)), + matches!(shutdown, Err(RecvTimeoutError::Disconnected)), "reader exit left the event channel live: {shutdown:?}" ); } diff --git a/cmux-tui/crates/cmux-tui-cdp/src/lib.rs b/cmux-tui/crates/cmux-tui-cdp/src/lib.rs index 69e3f633d25d..add5fa6f2b84 100644 --- a/cmux-tui/crates/cmux-tui-cdp/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-cdp/src/lib.rs @@ -12,4 +12,5 @@ pub use client::{ CDP_EVENT_QUEUE_CAPACITY, CDP_EVENT_QUEUE_MAX_BYTES, CapturedFrame, CdpClient, CdpEvent, CdpKeyEvent, FrameEpoch, NavigationEntry, NavigationHistory, ScreencastFrame, TargetCreated, TargetInfo, discover_browser_ws_url, event_retained_bytes, resolve_browser_ws_url, + run_host_resolver_helper, }; diff --git a/cmux-tui/crates/cmux-tui-core/Cargo.toml b/cmux-tui/crates/cmux-tui-core/Cargo.toml index 6d7067f7c545..1f6779ec5c24 100644 --- a/cmux-tui/crates/cmux-tui-core/Cargo.toml +++ b/cmux-tui/crates/cmux-tui-core/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] ghostty-vt.workspace = true cmux-tui-cdp.workspace = true +cmux-tui-process.workspace = true cmux-pty.workspace = true cmux-remote-protocol.workspace = true portable-pty.workspace = true diff --git a/cmux-tui/crates/cmux-tui-core/build.rs b/cmux-tui/crates/cmux-tui-core/build.rs new file mode 100644 index 000000000000..3120bc34365b --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/build.rs @@ -0,0 +1,406 @@ +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; +#[cfg(unix)] +use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + +const FNV_OFFSET: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; +const CMUX_TUI_SOURCE_PATHS: &[&str] = + &["Cargo.toml", "Cargo.lock", "bindings/rust", "crates", "vendor/crossterm"]; +const GHOSTTY_SOURCE_PATHS: &[&str] = &["build.zig", "build.zig.zon", "include", "pkg", "src"]; +const GHOSTTY_OPTIONAL_SOURCE_PATHS: &[&str] = &["VERSION"]; + +fn main() { + for name in [ + "CMUX_TUI_BUILD_COMMIT", + "CMUX_MUX_BUILD_COMMIT", + "CMUX_TUI_GHOSTTY_COMMIT", + "CMUX_GHOSTTY_SRC", + ] { + println!("cargo:rerun-if-env-changed={name}"); + } + + let manifest_dir = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR")); + let workspace_root = + manifest_dir.ancestors().nth(2).expect("cmux-tui-core is inside the workspace"); + let repository_root = + workspace_root.parent().expect("the cmux-tui workspace is inside the repository"); + let ghostty_root = env::var_os("CMUX_GHOSTTY_SRC") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| repository_root.join("ghostty")); + + track_source_paths(workspace_root, CMUX_TUI_SOURCE_PATHS); + track_source_paths(&ghostty_root, GHOSTTY_SOURCE_PATHS); + track_source_paths(&ghostty_root, GHOSTTY_OPTIONAL_SOURCE_PATHS); + track_git_identity(repository_root); + track_git_identity(&ghostty_root); + + if !nonempty_env("CMUX_TUI_BUILD_COMMIT") && !nonempty_env("CMUX_MUX_BUILD_COMMIT") { + let identity = source_identity(workspace_root, workspace_root, CMUX_TUI_SOURCE_PATHS) + .expect("derive an exact cmux-tui source identity"); + println!("cargo:rustc-env=CMUX_TUI_SOURCE_COMMIT={identity}"); + } + if !nonempty_env("CMUX_TUI_GHOSTTY_COMMIT") { + let ghostty_identity_paths = GHOSTTY_SOURCE_PATHS + .iter() + .copied() + .chain( + GHOSTTY_OPTIONAL_SOURCE_PATHS + .iter() + .copied() + .filter(|path| ghostty_root.join(path).exists()), + ) + .collect::>(); + let identity = source_identity(&ghostty_root, &ghostty_root, &ghostty_identity_paths) + .expect("derive an exact Ghostty source identity"); + println!("cargo:rustc-env=CMUX_TUI_SOURCE_GHOSTTY_COMMIT={identity}"); + } +} + +fn nonempty_env(name: &str) -> bool { + env::var_os(name).is_some_and(|value| !value.is_empty()) +} + +fn track_source_paths(root: &Path, paths: &[&str]) { + for path in paths { + println!("cargo:rerun-if-changed={}", root.join(path).display()); + } +} + +fn source_identity(git_root: &Path, fallback_root: &Path, paths: &[&str]) -> io::Result { + if let Some(commit) = optional_git_bytes(git_root, &["rev-parse", "HEAD"])? { + let commit = std::str::from_utf8(&commit) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "git commit is not UTF-8"))? + .trim(); + if commit.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "git commit is empty")); + } + let status = required_git_bytes_for_paths( + git_root, + &["status", "--porcelain=v1", "-z", "--untracked-files=all"], + paths, + )?; + if status.is_empty() { + return Ok(commit.to_string()); + } + return Ok(format!("{commit}-dirty-{:016x}", dirty_fingerprint(git_root, paths)?)); + } + directory_fingerprint(fallback_root, paths) + .map(|fingerprint| format!("source-{fingerprint:016x}")) +} + +fn dirty_fingerprint(root: &Path, paths: &[&str]) -> io::Result { + let mut hash = FNV_OFFSET; + hash_component( + &mut hash, + &required_git_bytes_for_paths(root, &["diff", "--binary", "--full-index", "HEAD"], paths)?, + ); + let untracked = required_git_bytes_for_paths( + root, + &["ls-files", "-z", "--others", "--exclude-standard"], + paths, + )?; + for path in untracked.split(|byte| *byte == 0).filter(|path| !path.is_empty()) { + hash_component(&mut hash, path); + hash_filesystem_entry(&mut hash, &path_from_git_bytes(root, path)?)?; + } + Ok(hash) +} + +fn directory_fingerprint(root: &Path, paths: &[&str]) -> io::Result { + fn visit(root: &Path, path: &Path, hash: &mut u64) -> io::Result<()> { + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let name = entry.file_name(); + if matches!( + name.to_str(), + Some(".git" | "target" | ".zig-cache" | "zig-cache" | "zig-out") + ) { + continue; + } + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_dir() { + visit(root, &path, hash)?; + } else { + hash_component( + hash, + path.strip_prefix(root).unwrap_or(&path).as_os_str().as_encoded_bytes(), + ); + hash_filesystem_entry(hash, &path)?; + } + } + Ok(()) + } + + let mut hash = FNV_OFFSET; + for path in paths { + let path = root.join(path); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_dir() { + visit(root, &path, &mut hash)?; + } else { + hash_component( + &mut hash, + path.strip_prefix(root).unwrap_or(&path).as_os_str().as_encoded_bytes(), + ); + hash_filesystem_entry(&mut hash, &path)?; + } + } + Ok(hash) +} + +fn hash_filesystem_entry(hash: &mut u64, path: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + hash_component(hash, b"symlink"); + hash_component(hash, fs::read_link(path)?.as_os_str().as_encoded_bytes()); + } else if metadata.file_type().is_file() { + hash_component(hash, b"file"); + hash_component(hash, &fs::read(path)?); + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported source entry {}", path.display()), + )); + } + Ok(()) +} + +fn hash_component(hash: &mut u64, bytes: &[u8]) { + hash_bytes(hash, &(bytes.len() as u64).to_le_bytes()); + hash_bytes(hash, bytes); +} + +fn hash_bytes(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(FNV_PRIME); + } +} + +fn track_git_identity(root: &Path) { + let Some(head) = git_text(root, &["rev-parse", "--git-path", "HEAD"]) else { return }; + println!("cargo:rerun-if-changed={}", resolve_git_path(root, &head).display()); + if let Some(reference) = git_text(root, &["symbolic-ref", "-q", "HEAD"]) + && let Some(reference_path) = + git_text(root, &["rev-parse", "--git-path", reference.as_str()]) + { + println!("cargo:rerun-if-changed={}", resolve_git_path(root, &reference_path).display()); + } + if let Some(packed_refs) = git_text(root, &["rev-parse", "--git-path", "packed-refs"]) { + println!("cargo:rerun-if-changed={}", resolve_git_path(root, &packed_refs).display()); + } +} + +fn resolve_git_path(root: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { path } else { root.join(path) } +} + +fn git_text(root: &Path, args: &[&str]) -> Option { + let output = optional_git_bytes(root, args).ok()??; + let value = std::str::from_utf8(&output).ok()?.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn optional_git_bytes(root: &Path, args: &[&str]) -> io::Result>> { + let output = match Command::new("git").arg("-C").arg(root).args(args).output() { + Ok(output) => output, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + Ok(output.status.success().then_some(output.stdout)) +} + +fn required_git_bytes(root: &Path, args: &[&str]) -> io::Result> { + optional_git_bytes(root, args)?.ok_or_else(|| { + io::Error::other(format!("git {} failed in {}", args.join(" "), root.display())) + }) +} + +fn required_git_bytes_for_paths(root: &Path, args: &[&str], paths: &[&str]) -> io::Result> { + let mut scoped = Vec::with_capacity(args.len() + paths.len() + 1); + scoped.extend_from_slice(args); + scoped.push("--"); + scoped.extend_from_slice(paths); + required_git_bytes(root, &scoped) +} + +#[cfg(unix)] +fn path_from_git_bytes(root: &Path, path: &[u8]) -> io::Result { + Ok(root.join(OsStr::from_bytes(path))) +} + +#[cfg(not(unix))] +fn path_from_git_bytes(root: &Path, path: &[u8]) -> io::Result { + let path = std::str::from_utf8(path) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "git path is not UTF-8"))?; + Ok(root.join(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::os::unix::fs::symlink; + use std::time::{SystemTime, UNIX_EPOCH}; + #[cfg(target_os = "linux")] + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + struct TestRepository { + root: PathBuf, + } + + impl TestRepository { + fn new(name: &str) -> Self { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let root = env::temp_dir() + .join(format!("cmux-source-identity-{name}-{}-{nonce}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + run_git(&root, &["init", "-q"]); + run_git(&root, &["config", "user.email", "tests@cmux.invalid"]); + run_git(&root, &["config", "user.name", "cmux tests"]); + fs::write(root.join("tracked"), b"tracked").unwrap(); + run_git(&root, &["add", "tracked"]); + run_git(&root, &["commit", "-qm", "initial"]); + Self { root } + } + } + + impl Drop for TestRepository { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn run_git(root: &Path, args: &[&str]) { + let output = Command::new("git").arg("-C").arg(root).args(args).output().unwrap(); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn dirty_identity_hashes_non_utf8_untracked_paths_and_contents() { + let repository = TestRepository::new("non-utf8"); + let name = OsString::from_vec(vec![b'n', b'o', b'n', b'-', 0xff]); + let path = repository.root.join(name); + fs::write(&path, b"one").unwrap(); + let first = source_identity(&repository.root, &repository.root, &["."]).unwrap(); + + fs::write(path, b"two").unwrap(); + let second = source_identity(&repository.root, &repository.root, &["."]).unwrap(); + + assert!(first.contains("-dirty-")); + assert!(second.contains("-dirty-")); + assert_ne!(first, second); + } + + #[cfg(unix)] + #[test] + fn dirty_identity_hashes_dangling_symlink_targets() { + let repository = TestRepository::new("dangling-symlink"); + let link = repository.root.join("untracked-link"); + symlink("missing-one", &link).unwrap(); + let first = source_identity(&repository.root, &repository.root, &["."]).unwrap(); + + fs::remove_file(&link).unwrap(); + symlink("missing-two", &link).unwrap(); + let second = source_identity(&repository.root, &repository.root, &["."]).unwrap(); + + assert_ne!(first, second); + } + + #[test] + fn dirty_identity_fails_closed_when_git_cannot_describe_changes() { + let repository = TestRepository::new("git-failure"); + fs::write(repository.root.join(".git/index"), b"invalid index").unwrap(); + + assert!(source_identity(&repository.root, &repository.root, &["."]).is_err()); + } + + #[test] + fn identity_and_invalidation_share_the_same_source_scope() { + let repository = TestRepository::new("scoped"); + fs::create_dir(repository.root.join("source")).unwrap(); + fs::create_dir(repository.root.join("docs")).unwrap(); + fs::write(repository.root.join("source/input"), b"one").unwrap(); + fs::write(repository.root.join("docs/guide"), b"one").unwrap(); + run_git(&repository.root, &["add", "source", "docs"]); + run_git(&repository.root, &["commit", "-qm", "add scoped inputs"]); + let clean = source_identity(&repository.root, &repository.root, &["source"]).unwrap(); + + fs::write(repository.root.join("docs/guide"), b"two").unwrap(); + let docs_changed = + source_identity(&repository.root, &repository.root, &["source"]).unwrap(); + fs::write(repository.root.join("source/input"), b"two").unwrap(); + let source_changed = + source_identity(&repository.root, &repository.root, &["source"]).unwrap(); + + assert_eq!(docs_changed, clean); + assert!(source_changed.contains("-dirty-")); + assert_ne!(source_changed, clean); + } + + #[test] + fn ghostty_identity_tracks_native_package_sources() { + let repository = TestRepository::new("ghostty-packages"); + fs::create_dir(repository.root.join("include")).unwrap(); + fs::create_dir(repository.root.join("src")).unwrap(); + fs::create_dir_all(repository.root.join("pkg/simdutf")).unwrap(); + fs::write(repository.root.join("build.zig"), b"build").unwrap(); + fs::write(repository.root.join("build.zig.zon"), b"dependencies").unwrap(); + fs::write(repository.root.join("include/ghostty.h"), b"header").unwrap(); + fs::write(repository.root.join("src/main.zig"), b"source").unwrap(); + fs::write(repository.root.join("pkg/simdutf/input"), b"one").unwrap(); + run_git(&repository.root, &["add", "."]); + run_git(&repository.root, &["commit", "-qm", "add native inputs"]); + let clean = + source_identity(&repository.root, &repository.root, GHOSTTY_SOURCE_PATHS).unwrap(); + + fs::write(repository.root.join("pkg/simdutf/input"), b"two").unwrap(); + let changed = + source_identity(&repository.root, &repository.root, GHOSTTY_SOURCE_PATHS).unwrap(); + + assert_ne!(changed, clean, "Ghostty package changes retained the clean build identity"); + } + + #[test] + fn cmux_identity_tracks_vendored_crossterm_sources() { + let repository = TestRepository::new("vendored-crossterm"); + fs::create_dir(repository.root.join("bindings")).unwrap(); + fs::create_dir(repository.root.join("bindings/rust")).unwrap(); + fs::create_dir(repository.root.join("crates")).unwrap(); + fs::create_dir(repository.root.join("vendor")).unwrap(); + fs::create_dir(repository.root.join("vendor/crossterm")).unwrap(); + fs::write(repository.root.join("Cargo.toml"), b"[workspace]").unwrap(); + fs::write(repository.root.join("Cargo.lock"), b"version = 4").unwrap(); + fs::write(repository.root.join("bindings/rust/input"), b"bindings").unwrap(); + fs::write(repository.root.join("crates/input"), b"crate").unwrap(); + fs::write(repository.root.join("vendor/crossterm/input"), b"one").unwrap(); + run_git(&repository.root, &["add", "."]); + run_git(&repository.root, &["commit", "-qm", "add cmux inputs"]); + let clean = + source_identity(&repository.root, &repository.root, CMUX_TUI_SOURCE_PATHS).unwrap(); + + fs::write(repository.root.join("vendor/crossterm/input"), b"two").unwrap(); + let changed = + source_identity(&repository.root, &repository.root, CMUX_TUI_SOURCE_PATHS).unwrap(); + + assert_ne!( + changed, clean, + "vendored Crossterm changes retained the clean cmux-tui build identity" + ); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 2275414e23a1..0daa4c6d61df 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Condvar, Mutex, Weak}; @@ -192,6 +192,19 @@ struct BrowserSession { session_id: String, } +#[derive(Clone)] +pub(crate) struct BrowserShutdownOwner(BrowserSession); + +impl BrowserShutdownOwner { + pub(crate) fn terminate_until(&self, deadline: Instant) -> bool { + self.0.runtime.close_surface_for_shutdown(&self.0.target_id, &self.0.session_id, deadline) + } + + pub(crate) fn runtime(&self) -> &Arc { + &self.0.runtime + } +} + struct BrowserState { latest_frame: Option>, // Frames are stamped at CDP ingress. A lifecycle barrier reserves the next @@ -584,6 +597,8 @@ pub struct BrowserRuntime { stealth_user_agent: Option, routes: Mutex, closed: AtomicBool, + closed_wait: Mutex<()>, + closed_changed: Condvar, } #[derive(Default)] @@ -748,19 +763,27 @@ const BROWSER_UPDATED_PAGE_VERIFICATION_FAILED_PREFIX: &str = const BROWSER_VERIFICATION_FAILED_SUFFIX: &str = "; reload to retry"; const AUTHORITY_CAPTURE_ATTEMPTS: usize = 3; #[cfg(not(test))] -const AUTHORITY_CAPTURE_ATTEMPT_BUDGET: Duration = Duration::from_secs(2); +fn authority_capture_attempt_budget() -> Duration { + Duration::from_secs(2) +} #[cfg(test)] -// A healthy capture performs seven serialized CDP round trips. The client's -// 20 ms read poll means 150 ms leaves essentially no scheduler margin. -const AUTHORITY_CAPTURE_ATTEMPT_BUDGET: Duration = Duration::from_millis(300); +fn authority_capture_attempt_budget() -> Duration { + // A healthy capture performs seven serialized CDP round trips. The + // client's 20 ms read poll means 150 ms leaves no scheduler margin. + crate::test_timeout(Duration::from_millis(300)) +} const NAVIGATION_AUTHORITY_TIMEOUT: Duration = Duration::from_secs(15); const POINTER_RELEASE_RETRY_DELAY: Duration = Duration::from_millis(250); const BROWSER_RECONFIGURE_RETRY_DELAYS: [Duration; 2] = [Duration::from_millis(250), Duration::from_millis(500)]; #[cfg(not(test))] -const NAVIGATION_COMMIT_WAIT: Duration = Duration::from_millis(250); +fn navigation_commit_wait() -> Duration { + Duration::from_millis(250) +} #[cfg(test)] -const NAVIGATION_COMMIT_WAIT: Duration = Duration::from_millis(100); +fn navigation_commit_wait() -> Duration { + crate::test_timeout(Duration::from_millis(100)) +} impl BrowserRuntime { pub fn connect(opts: &SurfaceOptions) -> anyhow::Result> { @@ -768,6 +791,16 @@ impl BrowserRuntime { Self::connect_to_endpoint(&web_socket_url, chrome, source) } + #[cfg(test)] + pub(crate) fn connect_external_for_test(web_socket_url: &str) -> anyhow::Result> { + Self::connect_to_endpoint(web_socket_url, None, BrowserSource::External) + } + + #[cfg(test)] + pub(crate) fn connect_launched_for_test(web_socket_url: &str) -> anyhow::Result> { + Self::connect_to_endpoint(web_socket_url, None, BrowserSource::Launched) + } + pub(crate) fn connect_provider( endpoint: &str, authentication: &BrowserProviderAuthentication, @@ -810,6 +843,8 @@ impl BrowserRuntime { stealth_user_agent, routes: Mutex::new(Routes::default()), closed: AtomicBool::new(false), + closed_wait: Mutex::new(()), + closed_changed: Condvar::new(), }); start_router(Arc::downgrade(&runtime), event_rx)?; runtime.client.set_discover_targets(true)?; @@ -820,6 +855,22 @@ impl BrowserRuntime { self.closed.load(Ordering::Acquire) } + #[cfg(test)] + pub(crate) fn wait_until_closed(&self, deadline: Instant) -> bool { + let mut wait = self.closed_wait.lock().unwrap(); + while !self.is_closed() { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.closed_changed.wait_timeout(wait, remaining).unwrap(); + wait = next; + if timeout.timed_out() && !self.is_closed() { + return false; + } + } + true + } + pub fn source(&self) -> BrowserSource { self.source } @@ -837,18 +888,14 @@ impl BrowserRuntime { pub(crate) fn bootstrap_surface_sync( self: &Arc, surface: Arc, - bootstrap: BrowserBootstrap, + target_id: String, + url: String, mux: Weak, ) -> anyhow::Result<()> { if self.is_closed() { anyhow::bail!("CDP browser connection is closed"); } - let (target_id, normalized_url) = match bootstrap { - BrowserBootstrap::ExistingTarget { target_id, url } => (target_id, normalize_url(&url)), - BrowserBootstrap::Provider { .. } => { - anyhow::bail!("browser provider target was not resolved before CDP bootstrap") - } - }; + let normalized_url = normalize_url(&url); let session_id = self.client.attach_to_target(&target_id)?; let events = self.register(&target_id, &session_id); if surface.as_browser().is_none() { @@ -957,18 +1004,166 @@ impl BrowserRuntime { } } - pub fn shutdown(&self) { - close_browser_runtime(self, "browser runtime shut down".to_string()); - let _ = self.client.flush_outbound(Duration::from_secs(1)); - if let Some(chrome) = &self.chrome { - chrome.kill(); + fn close_surface_for_shutdown( + &self, + target_id: &str, + session_id: &str, + deadline: Instant, + ) -> bool { + self.close_surfaces_for_shutdown(&[(target_id, session_id)], deadline) + .into_iter() + .next() + .unwrap_or(true) + } + + pub(crate) fn close_owners_for_shutdown( + &self, + owners: &[&BrowserShutdownOwner], + deadline: Instant, + ) -> Vec { + debug_assert!(owners.iter().all(|owner| std::ptr::eq(self, owner.runtime().as_ref()))); + let surfaces = owners + .iter() + .map(|owner| (owner.0.target_id.as_str(), owner.0.session_id.as_str())) + .collect::>(); + self.close_surfaces_for_shutdown(&surfaces, deadline) + } + + fn close_surfaces_for_shutdown( + &self, + surfaces: &[(&str, &str)], + deadline: Instant, + ) -> Vec { + for (target_id, session_id) in surfaces { + self.unregister(target_id, session_id); + } + if self.source == BrowserSource::Provider { + for (_, session_id) in surfaces { + let _ = self.client.detach_from_target_detached(session_id); + } + let flushed = deadline + .checked_duration_since(Instant::now()) + .is_some_and(|remaining| self.client.flush_outbound(remaining).is_ok()); + return vec![flushed; surfaces.len()]; + } + if surfaces.is_empty() { + return Vec::new(); + } + let targets = surfaces.iter().map(|(target_id, _)| *target_id).collect::>(); + let mut outcomes = if self.is_closed() { + vec![None; targets.len()] + } else { + Self::close_external_targets_until(&self.client, &targets, deadline) + }; + let unresolved = outcomes + .iter() + .enumerate() + .filter_map(|(index, outcome)| outcome.is_none().then_some(index)) + .collect::>(); + if unresolved.is_empty() { + return outcomes.into_iter().map(|outcome| outcome.unwrap_or(false)).collect(); + } + + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return outcomes.into_iter().map(|outcome| outcome.unwrap_or(false)).collect(); + }; + let (event_tx, _event_rx) = sync_channel(CDP_EVENT_QUEUE_CAPACITY); + let Ok(client) = self.client.reconnect_with_timeout(event_tx, remaining) else { + return outcomes.into_iter().map(|outcome| outcome.unwrap_or(false)).collect(); + }; + let retry_targets = unresolved.iter().map(|index| targets[*index]).collect::>(); + let retry_outcomes = Self::close_external_targets_until(&client, &retry_targets, deadline); + for (index, outcome) in unresolved.into_iter().zip(retry_outcomes) { + outcomes[index] = outcome; } + outcomes.into_iter().map(|outcome| outcome.unwrap_or(false)).collect() } -} -pub(crate) enum BrowserBootstrap { - ExistingTarget { target_id: String, url: String }, - Provider { tab_id: crate::resource::TabPublicId, url: String }, + fn close_external_targets_until( + client: &CdpClient, + target_ids: &[&str], + deadline: Instant, + ) -> Vec> { + let mut outcomes = vec![None; target_ids.len()]; + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return outcomes; + }; + let query_timeout = remaining / 3; + if query_timeout.is_zero() { + return outcomes; + } + let Ok(open_targets) = client.target_ids_with_timeout(query_timeout) else { + return outcomes; + }; + let open_targets = open_targets.into_iter().collect::>(); + let pending = target_ids + .iter() + .enumerate() + .filter_map(|(index, target_id)| { + if open_targets.contains(*target_id) { + Some(index) + } else { + outcomes[index] = Some(true); + None + } + }) + .collect::>(); + let mut ambiguous = Vec::new(); + for (position, index) in pending.iter().copied().enumerate() { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + ambiguous.extend_from_slice(&pending[position..]); + break; + }; + let slots = u32::try_from(pending.len() - position + 1).unwrap_or(u32::MAX); + let close_timeout = remaining / slots; + if close_timeout.is_zero() { + ambiguous.push(index); + continue; + } + if client.close_target_with_timeout(target_ids[index], close_timeout).is_ok() { + outcomes[index] = Some(true); + } else { + ambiguous.push(index); + } + } + if ambiguous.is_empty() { + return outcomes; + } + + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return outcomes; + }; + let Ok(open_targets) = client.target_ids_with_timeout(remaining) else { + return outcomes; + }; + let open_targets = open_targets.into_iter().collect::>(); + for index in ambiguous { + outcomes[index] = Some(!open_targets.contains(target_ids[index])); + } + outcomes + } + + pub fn shutdown(&self) { + let _ = self.shutdown_until(Instant::now() + Duration::from_secs(1)); + } + + pub(crate) fn shutdown_until(&self, deadline: Instant) -> bool { + let outbound_flushed = if self.source != BrowserSource::Launched { + if self.is_closed() { + true + } else { + deadline + .checked_duration_since(Instant::now()) + .is_some_and(|remaining| self.client.flush_outbound(remaining).is_ok()) + } + } else { + true + }; + close_browser_runtime(self, "browser runtime shut down".to_string()); + let transport_closed = self.client.close_until(deadline); + let browser_stopped = self.chrome.as_ref().is_none_or(|chrome| chrome.kill_until(deadline)); + outbound_flushed && transport_closed && browser_stopped + } } pub(crate) fn new_surface( @@ -1285,6 +1480,7 @@ fn start_router(runtime: Weak, events: Receiver) -> an } fn close_browser_runtime(runtime: &BrowserRuntime, reason: String) { + let closed_wait = runtime.closed_wait.lock().unwrap(); let senders = { let mut routes = runtime.routes.lock().unwrap(); runtime.closed.store(true, Ordering::Release); @@ -1293,6 +1489,8 @@ fn close_browser_runtime(runtime: &BrowserRuntime, reason: String) { routes.by_target.clear(); senders }; + runtime.closed_changed.notify_all(); + drop(closed_wait); for tx in senders { tx.close(reason.clone()); } @@ -1948,6 +2146,32 @@ fn emit_browser_failure(mux: &Weak, id: SurfaceId, message: String) { } impl BrowserSurface { + #[cfg(test)] + pub(crate) fn install_shutdown_session_for_test( + &self, + runtime: Arc, + target_id: &str, + session_id: &str, + ) { + *self.session.lock().unwrap() = Some(BrowserSession { + runtime, + target_id: target_id.to_string(), + session_id: session_id.to_string(), + }); + } + + pub(crate) fn shutdown_owner(&self) -> Option { + if !self.dead.swap(true, Ordering::AcqRel) { + self.close_taps(); + self.close_command_sender(); + } + self.session.lock().unwrap().take().map(BrowserShutdownOwner) + } + + pub(crate) fn has_shutdown_owner(&self) -> bool { + self.session.lock().unwrap().is_some() + } + pub fn latest_frame(&self) -> Option> { let state = self.state.lock().unwrap(); if matches!(state.status, BrowserStatus::Failed(_)) { @@ -2169,7 +2393,7 @@ impl BrowserSurface { return; } self.close_taps(); - if let Some(session) = self.session.lock().unwrap().take() { + if let Some(session) = self.session.lock().unwrap().clone() { session.runtime.close_surface_detached(&session.target_id, &session.session_id); } self.close_command_sender(); @@ -3914,7 +4138,8 @@ impl BrowserSurface { // Command acknowledgment does not mean the document committed. The // ingress navigation event owns this barrier and may arrive after the // short synchronous wait on a slow page. - let _ = self.frame_epoch.wait_until_at_least(expected_frame_epoch, NAVIGATION_COMMIT_WAIT); + let _ = + self.frame_epoch.wait_until_at_least(expected_frame_epoch, navigation_commit_wait()); } fn finish_navigation_command( @@ -4779,7 +5004,7 @@ impl BrowserSurface { frame_id, loader_id, navigation_epoch, - AUTHORITY_CAPTURE_ATTEMPT_BUDGET, + authority_capture_attempt_budget(), ) } @@ -4855,7 +5080,7 @@ impl BrowserSurface { if !self.needs_same_document_paint() { return Ok(BrowserWorkerSuccess::LocallySettled); } - let deadline = Instant::now() + AUTHORITY_CAPTURE_ATTEMPT_BUDGET; + let deadline = Instant::now() + authority_capture_attempt_budget(); match self.capture_main_frame_after_restart(&session, frame_id, loader_id, deadline) { Ok((frame_epoch, captured)) => { let accepted = self.accept_same_document_paint( @@ -4939,7 +5164,7 @@ impl BrowserSurface { ); return Ok(BrowserWorkerSuccess::LocallySettled); } - let deadline = Instant::now() + AUTHORITY_CAPTURE_ATTEMPT_BUDGET; + let deadline = Instant::now() + authority_capture_attempt_budget(); match session .runtime .client @@ -6411,6 +6636,446 @@ mod tests { waiter.join().unwrap(); } + #[test] + fn server_shutdown_bounds_external_target_confirmation() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (close_tx, close_rx) = mpsc::channel(); + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + write_ws_json(&mut ws, json!({"id": discover["id"], "result": {}})); + let query = read_ws_json(&mut ws); + assert_eq!(query["method"], "Target.getTargets"); + write_ws_json( + &mut ws, + json!({ + "id": query["id"], + "result": {"targetInfos": [{"targetId": "target-1"}]} + }), + ); + let close = read_ws_json(&mut ws); + assert_eq!(close["method"], "Target.closeTarget"); + assert_eq!(close["params"]["targetId"], "target-1"); + close_tx.send(()).unwrap(); + let _ = stop_rx.recv(); + }); + let runtime = super::BrowserRuntime::connect_to_endpoint( + &format!("ws://{addr}/devtools/browser/fake"), + None, + BrowserSource::External, + ) + .unwrap(); + + let started = Instant::now(); + assert!(!runtime.close_surface_for_shutdown( + "target-1", + "session-1", + started + Duration::from_millis(150), + )); + assert!(started.elapsed() < Duration::from_millis(500)); + close_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + stop_tx.send(()).unwrap(); + server.join().unwrap(); + } + + #[test] + fn server_shutdown_retains_a_target_when_close_reports_false() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_millis(500))).unwrap(); + let mut ws = accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + write_ws_json(&mut ws, json!({"id": discover["id"], "result": {}})); + let query = read_ws_json(&mut ws); + assert_eq!(query["method"], "Target.getTargets"); + write_ws_json( + &mut ws, + json!({ + "id": query["id"], + "result": {"targetInfos": [{"targetId": "target-1"}]} + }), + ); + let close = read_ws_json(&mut ws); + assert_eq!(close["method"], "Target.closeTarget"); + write_ws_json(&mut ws, json!({"id": close["id"], "result": {"success": false}})); + let confirmation = read_ws_json(&mut ws); + assert_eq!(confirmation["method"], "Target.getTargets"); + write_ws_json( + &mut ws, + json!({ + "id": confirmation["id"], + "result": {"targetInfos": [{"targetId": "target-1"}]} + }), + ); + }); + let runtime = super::BrowserRuntime::connect_to_endpoint( + &format!("ws://{addr}/devtools/browser/fake"), + None, + BrowserSource::External, + ) + .unwrap(); + + let terminated = runtime.close_surface_for_shutdown( + "target-1", + "session-1", + Instant::now() + Duration::from_secs(1), + ); + + runtime.shutdown(); + server.join().unwrap(); + assert!(!terminated, "Target.closeTarget success=false discarded the target owner"); + } + + #[test] + fn ordinary_launched_target_close_requires_confirmation() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let mut ws = accept(stream).unwrap(); + let version = read_ws_json(&mut ws); + assert_eq!(version["method"], "Browser.getVersion"); + write_ws_json( + &mut ws, + json!({"id": version["id"], "error": {"code": -32000, "message": "unavailable"}}), + ); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + write_ws_json(&mut ws, json!({"id": discover["id"], "result": {}})); + + let first = read_ws_json(&mut ws); + if first["method"] != "Target.getTargets" { + return false; + } + write_ws_json( + &mut ws, + json!({ + "id": first["id"], + "result": {"targetInfos": [{"targetId": "target-1"}]} + }), + ); + let close = read_ws_json(&mut ws); + assert_eq!(close["method"], "Target.closeTarget"); + write_ws_json(&mut ws, json!({"id": close["id"], "result": {"success": false}})); + let confirmation = read_ws_json(&mut ws); + assert_eq!(confirmation["method"], "Target.getTargets"); + write_ws_json( + &mut ws, + json!({ + "id": confirmation["id"], + "result": {"targetInfos": [{"targetId": "target-1"}]} + }), + ); + true + }); + let runtime = super::BrowserRuntime::connect_to_endpoint( + &format!("ws://{addr}/devtools/browser/fake"), + None, + BrowserSource::Launched, + ) + .unwrap(); + + let terminated = runtime.close_surface_for_shutdown( + "target-1", + "session-1", + Instant::now() + Duration::from_secs(1), + ); + + runtime.shutdown(); + let queried = server.join().unwrap(); + assert!(queried, "launched target close skipped the authoritative target query"); + assert!(!terminated, "unconfirmed launched target was discarded during ordinary close"); + } + + #[test] + fn server_shutdown_retries_an_unconfirmed_external_target() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (close_tx, close_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + write_ws_json(&mut ws, json!({"id": discover["id"], "result": {}})); + + let first_query = read_ws_json(&mut ws); + assert_eq!(first_query["method"], "Target.getTargets"); + write_ws_json( + &mut ws, + json!({ + "id": first_query["id"], + "result": {"targetInfos": [{"targetId": "target-1"}]} + }), + ); + let first_close = read_ws_json(&mut ws); + assert_eq!(first_close["method"], "Target.closeTarget"); + let first_confirmation = read_ws_json(&mut ws); + assert_eq!(first_confirmation["method"], "Target.getTargets"); + write_ws_json( + &mut ws, + json!({ + "id": first_confirmation["id"], + "result": {"targetInfos": [{"targetId": "target-1"}]} + }), + ); + close_tx.send(1).unwrap(); + + let second_query = read_ws_json(&mut ws); + assert_eq!(second_query["method"], "Target.getTargets"); + write_ws_json( + &mut ws, + json!({ + "id": second_query["id"], + "result": {"targetInfos": [{"targetId": "target-1"}]} + }), + ); + let second_close = read_ws_json(&mut ws); + assert_eq!(second_close["method"], "Target.closeTarget"); + assert_eq!(second_close["params"]["targetId"], "target-1"); + write_ws_json(&mut ws, json!({"id": second_close["id"], "result": {"success": true}})); + close_tx.send(2).unwrap(); + }); + let runtime = super::BrowserRuntime::connect_to_endpoint( + &format!("ws://{addr}/devtools/browser/fake"), + None, + BrowserSource::External, + ) + .unwrap(); + let surface = test_surface(); + let browser = surface.as_browser().unwrap(); + *browser.session.lock().unwrap() = Some(BrowserSession { + runtime: runtime.clone(), + target_id: "target-1".to_string(), + session_id: "session-1".to_string(), + }); + let owner = browser.shutdown_owner().unwrap(); + + assert!( + !owner + .terminate_until(Instant::now() + crate::test_timeout(Duration::from_millis(200))) + ); + assert_eq!(close_rx.recv_timeout(crate::test_timeout(Duration::from_secs(1))).unwrap(), 1); + assert!(browser.session.lock().unwrap().is_none()); + assert!( + owner.terminate_until(Instant::now() + crate::test_timeout(Duration::from_secs(1))) + ); + assert_eq!(close_rx.recv_timeout(crate::test_timeout(Duration::from_secs(1))).unwrap(), 2); + + runtime.shutdown(); + server.join().unwrap(); + } + + #[test] + fn server_shutdown_reconnects_to_confirm_a_target_after_transport_loss() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + write_ws_json(&mut ws, json!({"id": discover["id"], "result": {}})); + ws.close(None).unwrap(); + drop(ws); + + let (stream, _) = listener.accept().unwrap(); + let Ok(mut ws) = accept(stream) else { return false }; + let query = read_ws_json(&mut ws); + assert_eq!(query["method"], "Target.getTargets"); + write_ws_json(&mut ws, json!({"id": query["id"], "result": {"targetInfos": []}})); + true + }); + let runtime = super::BrowserRuntime::connect_to_endpoint( + &format!("ws://{addr}/devtools/browser/fake"), + None, + BrowserSource::External, + ) + .unwrap(); + let surface = test_surface(); + let browser = surface.as_browser().unwrap(); + *browser.session.lock().unwrap() = Some(BrowserSession { + runtime: runtime.clone(), + target_id: "target-1".to_string(), + session_id: "session-1".to_string(), + }); + let owner = browser.shutdown_owner().unwrap(); + assert!( + runtime.wait_until_closed(Instant::now() + Duration::from_secs(1)), + "initial CDP transport did not close" + ); + + let terminated = owner.terminate_until(Instant::now() + Duration::from_millis(500)); + if !terminated { + let _ = TcpStream::connect(addr); + } + + let runtime_released = runtime.shutdown_until(Instant::now() + Duration::from_millis(100)); + let reconnected = server.join().unwrap(); + assert!(terminated, "closed runtime made target cleanup permanently unretryable"); + assert!(reconnected, "shutdown did not reconnect to query the target"); + assert!(runtime_released, "closed external runtime remained permanently retained"); + } + + #[test] + fn server_shutdown_reconnect_obeys_its_deadline() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + write_ws_json(&mut ws, json!({"id": discover["id"], "result": {}})); + ws.close(None).unwrap(); + }); + let runtime = super::BrowserRuntime::connect_to_endpoint( + &format!("ws://{addr}/devtools/browser/fake"), + None, + BrowserSource::External, + ) + .unwrap(); + server.join().unwrap(); + let closed_deadline = Instant::now() + Duration::from_secs(1); + while !runtime.is_closed() && Instant::now() < closed_deadline { + thread::yield_now(); + } + assert!(runtime.is_closed(), "initial CDP transport did not close"); + let started = Instant::now(); + + let terminated = runtime.close_surface_for_shutdown( + "target-1", + "session-1", + Instant::now() + Duration::from_millis(50), + ); + + assert!(!terminated); + assert!( + started.elapsed() < Duration::from_millis(150), + "hostname resolution escaped the shutdown deadline: {:?}", + started.elapsed() + ); + } + + #[test] + fn server_shutdown_treats_a_missing_target_as_already_closed() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let mut ws = accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + write_ws_json(&mut ws, json!({"id": discover["id"], "result": {}})); + + let first = read_ws_json(&mut ws); + if first["method"] == "Target.getTargets" { + write_ws_json( + &mut ws, + json!({ + "id": first["id"], + "result": { + "targetInfos": [{"targetId": "target-1"}] + } + }), + ); + let close = read_ws_json(&mut ws); + assert_eq!(close["method"], "Target.closeTarget"); + assert_eq!(close["params"]["targetId"], "target-1"); + // Simulate Chrome applying the close while its response is + // lost. A post-timeout query must make the retry idempotent. + let query = read_ws_json(&mut ws); + assert_eq!(query["method"], "Target.getTargets"); + write_ws_json(&mut ws, json!({"id": query["id"], "result": {"targetInfos": []}})); + true + } else { + assert_eq!(first["method"], "Target.closeTarget"); + false + } + }); + let runtime = super::BrowserRuntime::connect_to_endpoint( + &format!("ws://{addr}/devtools/browser/fake"), + None, + BrowserSource::External, + ) + .unwrap(); + let surface = test_surface(); + let browser = surface.as_browser().unwrap(); + *browser.session.lock().unwrap() = Some(BrowserSession { + runtime: runtime.clone(), + target_id: "target-1".to_string(), + session_id: "session-1".to_string(), + }); + let owner = browser.shutdown_owner().unwrap(); + + let terminated = owner.terminate_until(Instant::now() + Duration::from_millis(400)); + + runtime.shutdown(); + let queried_after_close = server.join().unwrap(); + assert!(terminated, "an already-absent target remained a permanent shutdown failure"); + assert!(queried_after_close, "shutdown did not confirm target absence after close timeout"); + } + + #[test] + fn runtime_shutdown_waits_for_a_backpressured_detached_target_close() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (close_tx, close_rx) = mpsc::channel(); + let (read_tx, read_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + write_ws_json(&mut ws, json!({"id": discover["id"], "result": {}})); + + if read_rx.recv_timeout(Duration::from_secs(2)).is_err() { + return; + } + let close = read_ws_json(&mut ws); + assert_eq!(close["method"], "Target.closeTarget"); + assert_eq!(close["params"]["targetId"].as_str().map(str::len), Some(8 * 1024 * 1024)); + close_tx.send(()).unwrap(); + }); + let runtime = super::BrowserRuntime::connect_to_endpoint( + &format!("ws://{addr}/devtools/browser/fake"), + None, + BrowserSource::External, + ) + .unwrap(); + + runtime.close_surface_detached(&"x".repeat(8 * 1024 * 1024), "session-1"); + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + let shutdown_runtime = runtime; + let shutdown = thread::spawn(move || { + shutdown_tx + .send(shutdown_runtime.shutdown_until(Instant::now() + Duration::from_secs(2))) + .unwrap(); + }); + assert!( + shutdown_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "runtime shutdown returned before its queued target close was flushed" + ); + read_tx.send(()).unwrap(); + assert!(shutdown_rx.recv_timeout(Duration::from_secs(2)).unwrap()); + close_rx + .recv_timeout(Duration::from_secs(1)) + .expect("runtime shutdown dropped the detached target close"); + + shutdown.join().unwrap(); + server.join().unwrap(); + } + #[test] fn closed_surface_route_closes_its_cdp_target() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -6456,6 +7121,7 @@ mod tests { .recv_timeout(Duration::from_secs(1)) .expect("closed surface route did not close its CDP target"); assert!(browser.is_dead()); + assert!(browser.shutdown_owner().is_some()); assert!(browser.session.lock().unwrap().is_none()); runtime.shutdown(); @@ -9066,7 +9732,7 @@ mod tests { let addr = listener.local_addr().unwrap(); let server = thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); - stream.set_read_timeout(Some(Duration::from_millis(500))).unwrap(); + stream.set_read_timeout(Some(crate::test_timeout(Duration::from_millis(500)))).unwrap(); let mut ws = accept(stream).unwrap(); let discover = read_ws_json(&mut ws); assert_eq!(discover["method"], "Target.setDiscoverTargets"); @@ -9516,6 +10182,7 @@ mod tests { assert_eq!(reload["method"], "Page.reload"); write_ws_json(&mut ws, json!({"id": reload["id"], "result": {}})); + ws.get_ref().set_read_timeout(Some(BROWSER_TEST_EVENT_TIMEOUT)).unwrap(); let Ok(message) = ws.read() else { superseded_tx.send(false).unwrap(); return; @@ -9632,6 +10299,7 @@ mod tests { assert_eq!(first_reload["method"], "Page.reload"); write_ws_json(&mut ws, json!({"id": first_reload["id"], "result": {}})); + ws.get_ref().set_read_timeout(Some(BROWSER_TEST_EVENT_TIMEOUT)).unwrap(); let Ok(message) = ws.read() else { superseded_tx.send(false).unwrap(); return; @@ -10366,8 +11034,9 @@ mod tests { browser.store_frame(test_frame(1)); let result = browser.navigate_blocking("https://example.test#same-document"); - let post_navigate_delay = - post_navigate_delay_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let post_navigate_delay = post_navigate_delay_rx + .recv_timeout(crate::test_timeout(Duration::from_secs(1))) + .unwrap(); let state = browser.state.lock().unwrap(); let pending_frame_epoch = state.pending_frame_epoch; let pending_navigation_epoch = state.pending_navigation_epoch; @@ -10388,7 +11057,7 @@ mod tests { "the unchanged document must regain authority through freshly captured pixels" ); assert!( - post_navigate_delay < super::NAVIGATION_COMMIT_WAIT / 2, + post_navigate_delay < super::navigation_commit_wait() / 2, "loaderless same-document navigation waited {post_navigate_delay:?} for an epoch that cannot advance" ); } diff --git a/cmux-tui/crates/cmux-tui-core/src/journal_hooks.rs b/cmux-tui/crates/cmux-tui-core/src/journal_hooks.rs index d20d8f2f81c6..b5ef85abb2e1 100644 --- a/cmux-tui/crates/cmux-tui-core/src/journal_hooks.rs +++ b/cmux-tui/crates/cmux-tui-core/src/journal_hooks.rs @@ -902,7 +902,7 @@ fn execute_delivery_with_shutdown( } #[cfg(unix)] tree.configure(&mut command); - let mut child = match command.spawn() { + let mut child = match cmux_tui_process::spawn(&mut command) { Ok(child) => child, Err(error) => return (None, Some(format!("start hook executable: {error}"))), }; @@ -1361,6 +1361,43 @@ mod tests { assert_eq!(delivery_worker_count(usize::MAX), 32); } + #[cfg(target_os = "macos")] + #[test] + fn journal_hook_launch_waits_for_process_creation_barrier() { + let barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let delivery = JournalHookDelivery { + manifest: manifest(), + event: document("plugin.test.process_barrier", json!({})).record, + attempt: 0, + }; + let attempt = JournalHookAttempt { attempt: 1, causation_id: "barrier_test".into() }; + let (started_sender, started_receiver) = mpsc::sync_channel(1); + let (completed_sender, completed_receiver) = mpsc::sync_channel(1); + let worker = std::thread::spawn(move || { + started_sender.send(()).unwrap(); + completed_sender.send(execute_delivery(&delivery, &attempt)).unwrap(); + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("journal hook launch worker did not start"); + + assert!( + matches!( + completed_receiver.recv_timeout(Duration::from_millis(250)), + Err(mpsc::RecvTimeoutError::Timeout) + ), + "journal hook launch bypassed the process creation barrier" + ); + drop(barrier); + + let (status, error) = completed_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("journal hook did not resume after the process creation barrier opened"); + assert_eq!(status, Some(0)); + assert_eq!(error, None); + worker.join().unwrap(); + } + #[cfg(target_os = "linux")] #[test] fn unix_hook_tree_kills_a_descendant_that_created_a_new_session() { diff --git a/cmux-tui/crates/cmux-tui-core/src/journal_ingress.rs b/cmux-tui/crates/cmux-tui-core/src/journal_ingress.rs index 2d02d28ff010..6fd0dd10f60a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/journal_ingress.rs +++ b/cmux-tui/crates/cmux-tui-core/src/journal_ingress.rs @@ -1278,7 +1278,7 @@ mod tests { let shutdown_mux = mux.clone(); let (completed, completion) = sync_channel(1); let shutdown = std::thread::spawn(move || { - shutdown_mux.shutdown(); + shutdown_mux.shutdown().unwrap(); completed.send(()).unwrap(); }); assert!( @@ -1331,7 +1331,7 @@ mod tests { ); let started = Instant::now(); - mux.shutdown(); + mux.shutdown().unwrap(); assert!( started.elapsed() < JOURNAL_DURABLE_WAIT + Duration::from_secs(2), @@ -1719,7 +1719,7 @@ mod tests { let shutdown_mux = mux.clone(); let (shutdown_completed, shutdown_completion) = sync_channel(1); let shutdown = std::thread::spawn(move || { - shutdown_mux.shutdown(); + shutdown_mux.shutdown().unwrap(); shutdown_completed.send(()).unwrap(); }); let returned = shutdown_completion.recv_timeout(Duration::from_secs(5)); @@ -1773,7 +1773,7 @@ mod tests { let shutdown_mux = mux.clone(); let (completed, completion) = sync_channel(1); let shutdown = std::thread::spawn(move || { - shutdown_mux.shutdown(); + shutdown_mux.shutdown().unwrap(); completed.send(()).unwrap(); }); assert!( @@ -1891,7 +1891,7 @@ mod tests { } let started = Instant::now(); - mux.shutdown(); + mux.shutdown().unwrap(); assert!( started.elapsed() < Duration::from_secs(3), @@ -1975,7 +1975,7 @@ mod tests { .unwrap(); let started = Instant::now(); - mux.shutdown(); + mux.shutdown().unwrap(); assert!( started.elapsed() < Duration::from_secs(3), "shutdown waited without a bound for a descendant-held PTY" diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 6913346a5c36..e4d0bdd6b81c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -19,7 +19,10 @@ mod journal_kernel; mod model; mod mux; mod pairing; +#[cfg(unix)] +pub mod process_session; pub mod provider_management; +mod publication; pub mod resource; mod resource_api; mod resource_mutation; @@ -27,11 +30,13 @@ mod resource_router; mod resource_selector; mod short_id; mod sidebar_resource; +mod spawned_pty_child; mod surface; mod workspace_registry; pub mod layout; pub mod platform; +pub mod release; pub mod server; pub mod terminal_host; pub mod terminal_host_protocol; @@ -59,11 +64,12 @@ pub use mux::{ LayoutRatioError, LayoutSpec, LayoutUndoError, LayoutUndoResult, Mux, MuxEvent, NotificationEvent, NotificationLevel, ProviderWorkspaceAuthority, ProviderWorkspaceAuthorityStatus, ProviderWorkspaceAuthorityUpdateError, ResourceNotification, - RunPlacement, SidebarPluginOptions, SidebarPluginStatus, SurfaceNotification, - SurfaceResizeReporter, TreeDelta, TreeDeltaKind, ViewportWidthError, WorkspaceMutationResult, - WorkspacePlacement, ZoomMode, ZoomState, + RunPlacement, ShutdownRequestWatch, SidebarPluginOptions, SidebarPluginStatus, + SurfaceNotification, SurfaceResizeReporter, TreeDelta, TreeDeltaKind, ViewportWidthError, + WorkspaceMutationResult, WorkspacePlacement, ZoomMode, ZoomState, }; pub use pairing::{PairingChallenge, PairingDecision, PairingError}; +pub use publication::PublicationGuard; pub use resource_api::{ResourceMachineRequest, ResourceMachineService}; pub use resource_selector::{ResolvedResourcePath, ResourceSelectors, ResourceTarget}; pub use short_id::assign_short_ids; @@ -99,6 +105,20 @@ pub type SplitId = u64; pub type ScreenId = u64; pub type WorkspaceId = u64; +#[cfg(test)] +pub(crate) fn test_timeout_scale() -> u32 { + std::env::var("CMUX_TEST_TIMEOUT_SCALE") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|scale| *scale > 0) + .unwrap_or(1) +} + +#[cfg(test)] +pub(crate) fn test_timeout(duration: std::time::Duration) -> std::time::Duration { + duration.saturating_mul(test_timeout_scale()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SplitDir { /// Split into left/right columns. diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index c4dbd152906f..39af32408fe8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -18,6 +18,7 @@ use std::sync::{ Arc, Condvar, LockResult, Mutex, MutexGuard, OnceLock, PoisonError, TryLockError, TryLockResult, Weak, }; +use std::thread::ThreadId; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::Context; @@ -26,7 +27,7 @@ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use zeroize::Zeroize; -use crate::browser::{self, BrowserBootstrap, BrowserRuntime}; +use crate::browser::{self, BrowserRuntime, BrowserSource}; use crate::browser_provider::{ BrowserProviderRegistration, BrowserProviderRegistry, BrowserProviderSnapshot, BrowserProviderTargetLease, @@ -53,7 +54,9 @@ use crate::resource_mutation::{ResourceMutationMetrics, ResourceMutationPlan}; use crate::resource_selector::{ ResolvedResourceSlots, ResourceSelectorContext, resolve_resource_selectors, }; -use crate::surface::{DefaultColors, Surface, SurfaceOptions}; +#[cfg(unix)] +use crate::surface::SurfaceShutdownOwnerIdentity; +use crate::surface::{DefaultColors, Surface, SurfaceOptions, SurfaceShutdownOwner}; use crate::terminal_host::TerminalId; use crate::terminal_host_protocol::TerminalExit; use crate::terminal_host_runtime::TerminalHostIdentity; @@ -73,6 +76,26 @@ use crate::{ }; pub type SurfaceResizeReporter = Arc) + Send + Sync>; +#[cfg(test)] +type ShutdownAttemptHook = Arc; +#[cfg(unix)] +type TerminalHostRecords = + Vec<(std::path::PathBuf, crate::terminal_host_runtime::TerminalHostRecord)>; +#[cfg(not(unix))] +type TerminalHostRecords = (); +const TERMINAL_HOST_CLEANUP_RECEIPT_FIELD: &str = "terminal_host_cleanup"; +const MAX_TERMINAL_HOST_CLEANUP_IDENTITIES: usize = 4_096; +#[cfg(all(test, unix))] +type TerminalAdoptionSurfaceFactory = + Arc anyhow::Result> + Send + Sync>; +#[cfg(all(test, unix))] +type TerminalHostRecordLoader = Arc< + dyn Fn(std::path::PathBuf, usize, Instant) -> anyhow::Result + Send + Sync, +>; +#[cfg(test)] +type NewPaneAfterSpawnHook = Arc) + Send + Sync>; +#[cfg(test)] +type BrowserTabAfterSpawnHook = Arc) + Send + Sync>; struct SignaledMutex { value: Mutex, @@ -204,6 +227,506 @@ const WORKSPACE_KEY_MAX_BYTES: usize = 256; const WORKSPACE_NAME_MAX_BYTES: usize = 1_024; const PROVIDER_WORKSPACE_AUTHORITY_MIN_BYTES: usize = 32; const PROVIDER_WORKSPACE_AUTHORITY_MAX_BYTES: usize = 512; +const SHUTDOWN_TERMINATION_TIMEOUT: Duration = Duration::from_millis(100); +const SHUTDOWN_FANOUT_WORKERS: usize = 32; +const SHUTDOWN_RECONCILE_INITIAL_DELAY: Duration = Duration::from_millis(25); +const SHUTDOWN_RECONCILE_MAX_DELAY: Duration = Duration::from_secs(1); +const SERVER_EXIT_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(25); +const SERVER_EXIT_RETRY_MAX_DELAY: Duration = Duration::from_secs(1); +#[cfg(test)] +const SERVER_EXIT_TEST_ATTEMPT_BUDGET: u32 = 8; +const SHUTDOWN_OWNER_CAPACITY: usize = 4_096; +#[cfg(unix)] +const TERMINAL_ADOPTION_QUEUE_CAPACITY: usize = SHUTDOWN_OWNER_CAPACITY; +#[cfg(unix)] +const TERMINAL_ADOPTION_DEFERRED_CAPACITY: usize = TERMINAL_ADOPTION_QUEUE_CAPACITY; +#[cfg(unix)] +const TERMINAL_ADOPTION_WORKERS: usize = 4; +#[cfg(not(test))] +const SHUTDOWN_RECONCILE_MAX_ATTEMPTS: usize = 8; +#[cfg(test)] +const SHUTDOWN_RECONCILE_MAX_ATTEMPTS: usize = 3; + +#[cfg(unix)] +struct TerminalAdoptionTask { + options: SurfaceOptions, + record: crate::terminal_host_runtime::TerminalHostRecord, + record_path: std::path::PathBuf, + next_attempt: Instant, + delay: Duration, +} + +#[cfg(unix)] +struct TerminalAdoptionQueueState { + tasks: Vec, + deferred: VecDeque, + in_flight: usize, + rescan_required: bool, + next_rescan: Option, + rescan_delay: Duration, + workers_running: usize, + stopping: bool, +} + +#[cfg(unix)] +impl Default for TerminalAdoptionQueueState { + fn default() -> Self { + Self { + tasks: Vec::new(), + deferred: VecDeque::new(), + in_flight: 0, + rescan_required: false, + next_rescan: None, + rescan_delay: Duration::from_millis(100), + workers_running: 0, + stopping: false, + } + } +} + +#[cfg(unix)] +impl TerminalAdoptionQueueState { + fn active_len(&self) -> usize { + self.tasks.len() + self.in_flight + } + + fn enqueue(&mut self, task: TerminalAdoptionTask) -> bool { + if self.active_len() < TERMINAL_ADOPTION_QUEUE_CAPACITY { + self.tasks.push(task); + return true; + } + if self.deferred.len() < TERMINAL_ADOPTION_DEFERRED_CAPACITY { + self.deferred.push_back(task); + return true; + } + false + } + + fn promote_deferred(&mut self) { + while self.active_len() < TERMINAL_ADOPTION_QUEUE_CAPACITY { + let Some(task) = self.deferred.pop_front() else { break }; + self.tasks.push(task); + } + } + + fn requeue_retry(&mut self, task: TerminalAdoptionTask) { + if let Some(next) = self.deferred.pop_front() { + self.tasks.push(next); + self.deferred.push_back(task); + } else { + self.tasks.push(task); + } + } + + fn rescan_due(&self, now: Instant) -> bool { + self.rescan_required + && self.deferred.is_empty() + && self.next_rescan.is_none_or(|next_rescan| next_rescan <= now) + } +} + +#[cfg(all(test, unix))] +impl TerminalAdoptionCoordinator { + fn wait_until_idle(&self, tracked: &Mutex>, deadline: Instant) -> bool { + let mut state = self.state.lock().unwrap(); + loop { + if state.in_flight == 0 && tracked.lock().unwrap().is_empty() { + return true; + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.wake.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && (state.in_flight != 0 || !tracked.lock().unwrap().is_empty()) + { + return false; + } + } + } +} + +#[cfg(unix)] +#[derive(Default)] +struct TerminalAdoptionCoordinator { + state: Mutex, + wake: Condvar, +} + +#[cfg(unix)] +struct TerminalHostRecordScanTask { + root: std::path::PathBuf, + capacity: usize, + result: Receiver>, + worker: std::thread::JoinHandle<()>, +} + +#[cfg(unix)] +#[derive(Default)] +struct TerminalHostRecordScanOwner { + task: Mutex>, +} + +#[cfg(unix)] +impl TerminalHostRecordScanOwner { + fn scan_until( + &self, + root: std::path::PathBuf, + capacity: usize, + deadline: Instant, + loader: F, + ) -> anyhow::Result + where + F: FnOnce(std::path::PathBuf, usize, Instant) -> anyhow::Result + + Send + + 'static, + { + let mut task = self.task.lock().unwrap(); + if let Some(active) = task.as_ref() { + anyhow::ensure!( + active.root == root && active.capacity == capacity, + "terminal-host record scan parameters changed during shutdown" + ); + } else { + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + let scan_root = root.clone(); + let worker = std::thread::Builder::new() + .name("terminal-host-record-scan".into()) + .spawn(move || { + let result = loader(scan_root, capacity, deadline); + let _ = result_tx.send(result); + }) + .context("start terminal-host record scan")?; + *task = Some(TerminalHostRecordScanTask { root, capacity, result: result_rx, worker }); + } + + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + anyhow::bail!("terminal-host record scan exceeded the shutdown deadline"); + }; + let received = task + .as_ref() + .expect("terminal-host record scan was installed") + .result + .recv_timeout(remaining); + match received { + Ok(result) => { + let completed = task.take().expect("completed record scan remained installed"); + drop(task); + if completed.worker.join().is_err() { + anyhow::bail!("terminal-host record scan worker panicked"); + } + result + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + anyhow::bail!("terminal-host record scan exceeded the shutdown deadline") + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + let failed = task.take().expect("failed record scan remained installed"); + drop(task); + let _ = failed.worker.join(); + anyhow::bail!("terminal-host record scan worker stopped without a result") + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum ShutdownOwnerKey { + Surface(SurfaceId), + Hosted { terminal_id: String, incarnation: String }, +} + +enum ShutdownOwnerWork { + Single((ShutdownOwnerKey, Arc)), + BrowserBatch { + runtime: Arc, + owners: Vec<(ShutdownOwnerKey, Arc)>, + }, +} + +impl ShutdownOwnerWork { + fn record_failed_keys(&self, failed: &mut HashSet) { + match self { + Self::Single((key, _)) => { + failed.insert(key.clone()); + } + Self::BrowserBatch { owners, .. } => { + failed.extend(owners.iter().map(|(key, _)| key.clone())); + } + } + } +} + +fn shutdown_owner_key(surface: SurfaceId, owner: &SurfaceShutdownOwner) -> ShutdownOwnerKey { + owner + .hosted_identity() + .map(|(terminal_id, incarnation)| ShutdownOwnerKey::Hosted { + terminal_id: terminal_id.to_string(), + incarnation: incarnation.to_string(), + }) + .unwrap_or(ShutdownOwnerKey::Surface(surface)) +} + +#[derive(Default)] +struct ShutdownOwnerLedger { + owners: Mutex>>, +} + +impl ShutdownOwnerLedger { + fn get(&self, key: &ShutdownOwnerKey) -> Option<(ShutdownOwnerKey, Arc)> { + self.owners.lock().unwrap().get(key).cloned().map(|owner| (key.clone(), owner)) + } + + fn stage( + &self, + key: ShutdownOwnerKey, + owner: SurfaceShutdownOwner, + ) -> (ShutdownOwnerKey, Arc) { + let staged = self + .owners + .lock() + .unwrap() + .entry(key.clone()) + .or_insert_with(|| Arc::new(owner)) + .clone(); + (key, staged) + } + + fn stage_surface( + &self, + surface: &Arc, + ) -> Option<(ShutdownOwnerKey, Arc)> { + let surface_key = ShutdownOwnerKey::Surface(surface.id); + if let Some(staged) = self.get(&surface_key) { + return Some(staged); + } + let owner = surface.shutdown_owner()?; + Some(self.stage(shutdown_owner_key(surface.id, &owner), owner)) + } + + fn stage_local_process_surface( + &self, + surface: &Arc, + ) -> Option<(ShutdownOwnerKey, Arc)> { + let owner = surface.local_process_shutdown_owner()?; + Some(self.stage(ShutdownOwnerKey::Surface(surface.id), owner)) + } + + fn snapshot(&self) -> Vec<(ShutdownOwnerKey, Arc)> { + self.owners + .lock() + .unwrap() + .iter() + .map(|(key, owner)| (key.clone(), owner.clone())) + .collect() + } + + fn remove_confirmed(&self, confirmed: &[(ShutdownOwnerKey, Arc)]) { + let mut owners = self.owners.lock().unwrap(); + for (key, confirmed) in confirmed { + if owners.get(key).is_some_and(|owner| Arc::ptr_eq(owner, confirmed)) { + owners.remove(key); + } + } + } + + fn remove_browser_runtime(&self, runtime: &Arc) { + self.owners.lock().unwrap().retain(|_, owner| { + owner.browser_runtime().is_none_or(|owner_runtime| !Arc::ptr_eq(owner_runtime, runtime)) + }); + } + + fn len(&self) -> usize { + self.owners.lock().unwrap().len() + } + + #[cfg(test)] + fn is_empty(&self) -> bool { + self.owners.lock().unwrap().is_empty() + } +} + +#[derive(Default)] +struct ShutdownOwnerReconcilerState { + pending: bool, + stopping: bool, + worker_started: bool, + degraded: bool, + revision: u64, +} + +#[derive(Default)] +struct ShutdownOwnerReconciler { + state: Mutex, + wake: Condvar, + mux: OnceLock>, + #[cfg(test)] + after_attempt: Mutex>, +} + +impl ShutdownOwnerReconciler { + fn bind(&self, mux: Weak) { + assert!(self.mux.set(mux).is_ok(), "shutdown owner reconciler was bound twice"); + } + + #[cfg(test)] + fn worker_started(&self) -> bool { + self.state.lock().unwrap().worker_started + } + + #[cfg(test)] + fn wait_until(&self, deadline: Instant, predicate: impl Fn() -> bool) -> bool { + let mut state = self.state.lock().unwrap(); + while !predicate() { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let revision = state.revision; + let (next, timeout) = self + .wake + .wait_timeout_while(state, remaining, |state| state.revision == revision) + .unwrap(); + state = next; + if timeout.timed_out() && !predicate() { + return false; + } + } + true + } + + #[cfg(test)] + fn wait_until_degraded(&self, deadline: Instant) -> bool { + let mut state = self.state.lock().unwrap(); + while !state.degraded { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.wake.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && !state.degraded { + return false; + } + } + true + } + + fn publish_progress(&self) { + let mut state = self.state.lock().unwrap(); + state.revision = state.revision.wrapping_add(1); + self.wake.notify_all(); + } + + fn wait_for_retry(&self, delay: Duration, deadline: Instant) { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { return }; + let state = self.state.lock().unwrap(); + let _ = self.wake.wait_timeout(state, delay.min(remaining)).unwrap(); + } + + fn schedule(self: &Arc) { + let mut state = self.state.lock().unwrap(); + if state.stopping { + return; + } + state.pending = true; + state.degraded = false; + if state.worker_started { + self.wake.notify_one(); + return; + } + state.worker_started = true; + drop(state); + + let mux = + self.mux.get().cloned().expect("shutdown owner reconciler must bind before scheduling"); + let reconciler = self.clone(); + if std::thread::Builder::new() + .name("cmux-shutdown-owner-reconciler".into()) + .spawn(move || reconciler.run(mux)) + .is_err() + { + // Ownership remains in the ledger for full shutdown. A later + // retirement will retry worker creation if process pressure eases. + let mut state = self.state.lock().unwrap(); + state.worker_started = false; + state.degraded = true; + } + } + + fn stop(&self) { + let mut state = self.state.lock().unwrap(); + state.stopping = true; + self.wake.notify_all(); + } + + fn run(self: Arc, mux: Weak) { + let mut delay = SHUTDOWN_RECONCILE_INITIAL_DELAY; + loop { + let mut state = self.state.lock().unwrap(); + while !state.pending && !state.stopping { + state = self.wake.wait(state).unwrap(); + } + if state.stopping { + return; + } + state.pending = false; + drop(state); + + let mut attempts = 0; + loop { + let Some(mux) = mux.upgrade() else { return }; + let deadline = Instant::now() + SHUTDOWN_TERMINATION_TIMEOUT; + let pending = match mux.lock_shutdown_coordinator_until(deadline) { + Ok(_coordinator) => mux.terminate_staged_shutdown_owners_until(deadline), + Err(_) => true, + }; + drop(mux); + self.publish_progress(); + if !pending { + delay = SHUTDOWN_RECONCILE_INITIAL_DELAY; + break; + } + attempts += 1; + #[cfg(test)] + if let Some(hook) = self.after_attempt.lock().unwrap().clone() { + hook(attempts); + } + if attempts >= SHUTDOWN_RECONCILE_MAX_ATTEMPTS { + let mut state = self.state.lock().unwrap(); + if state.pending { + // The final sweep did not include work scheduled + // while it was running. Give that new ownership batch + // its own bounded retry budget. + state.pending = false; + state.degraded = false; + attempts = 0; + delay = SHUTDOWN_RECONCILE_INITIAL_DELAY; + drop(state); + continue; + } + state.pending = false; + state.worker_started = false; + state.degraded = true; + state.revision = state.revision.wrapping_add(1); + self.wake.notify_all(); + return; + } + + let state = self.state.lock().unwrap(); + let mut state = if state.pending { + state + } else { + self.wake.wait_timeout(state, delay).unwrap().0 + }; + if state.stopping { + return; + } + state.pending = false; + drop(state); + delay = delay.saturating_mul(2).min(SHUTDOWN_RECONCILE_MAX_DELAY); + } + } + } +} const CELL_PIXEL_FANOUT_MAX_WORKERS: usize = 32; const DEADLINE_FANOUT_IDLE_TIMEOUT: Duration = Duration::from_millis(250); const CELL_PIXEL_RETRY_INITIAL: Duration = Duration::from_millis(25); @@ -1344,23 +1867,91 @@ pub struct SidebarPluginStatus { pub retry_after: Option, } -#[derive(Debug, Default)] -struct SidebarPluginRuntime { - options: Option, - surface: Option, - last_size: Option<(u16, u16)>, - last_error: Option, - failures: u32, - retry_at: Option, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ShutdownCleanupHealth { + pub(crate) pending: usize, + pub(crate) retrying: bool, + pub(crate) degraded: bool, } -enum BrowserSurfaceAttach { - MissingPane, - Attached(Option), +#[derive(Clone, Copy, Debug, Default)] +struct ShutdownCleanupLifecycleState { + pending: bool, + retrying: bool, + degraded: bool, } -type ClientSurfaceSizes = HashMap>; -type SurfaceResizeAcceptance = (bool, Option); +#[derive(Default)] +struct ShutdownCleanupLifecycle { + state: Mutex, +} + +impl ShutdownCleanupLifecycle { + /// Publish cleanup before closing the creation fence. The state remains + /// pending until one complete top-level cleanup path succeeds, including + /// failures that have no terminal owner in `ShutdownOwnerLedger`. + fn schedule(&self) { + *self.state.lock().unwrap() = + ShutdownCleanupLifecycleState { pending: true, retrying: true, degraded: false }; + } + + fn begin(&self) -> ShutdownCleanupLifecycleGuard<'_> { + self.schedule(); + ShutdownCleanupLifecycleGuard { lifecycle: self, complete: false } + } + + fn snapshot(&self) -> ShutdownCleanupLifecycleState { + *self.state.lock().unwrap() + } + + fn complete(&self) { + *self.state.lock().unwrap() = ShutdownCleanupLifecycleState::default(); + } + + fn degrade(&self) { + *self.state.lock().unwrap() = + ShutdownCleanupLifecycleState { pending: true, retrying: false, degraded: true }; + } +} + +struct ShutdownCleanupLifecycleGuard<'a> { + lifecycle: &'a ShutdownCleanupLifecycle, + complete: bool, +} + +impl ShutdownCleanupLifecycleGuard<'_> { + fn complete(mut self) { + self.lifecycle.complete(); + self.complete = true; + } +} + +impl Drop for ShutdownCleanupLifecycleGuard<'_> { + fn drop(&mut self) { + if !self.complete { + self.lifecycle.degrade(); + } + } +} + +#[derive(Debug, Default)] +struct SidebarPluginRuntime { + options: Option, + surface: Option, + last_size: Option<(u16, u16)>, + last_error: Option, + failures: u32, + retry_at: Option, +} + +enum BrowserSurfaceAttach { + MissingPane, + Rejected, + Attached(Option), +} + +type ClientSurfaceSizes = HashMap>; +type SurfaceResizeAcceptance = (bool, Option); type AppliedClientSize = (SurfaceResizeAcceptance, Option<(u16, u16)>, ClientSizeRollback); type SurfaceResizeOutcome = Result<(), Arc>; type SurfaceResizeCompletion = SyncSender; @@ -1389,1200 +1980,1747 @@ impl Drop for PendingWorkspaceSurface<'_> { } } -enum SurfaceResizeRestore { - Complete(bool), - Pending(Receiver), +#[derive(Default)] +struct SurfaceCreationState { + shutting_down: bool, + active_threads: HashMap, } -#[derive(PartialEq, Eq)] -struct ClientSizingRollbackToken { - surface_sizes: Option>, - surface_orders: HashMap, - participating_surface_clients: HashSet, - uses_excluded_fallback: bool, +#[derive(Default)] +struct SurfaceCreationGate { + state: Mutex, + idle: Condvar, } -#[derive(Clone, Copy)] -pub(crate) struct ClientSizeRollback { - pub(crate) previous_size: Option<(u16, u16)>, - pub(crate) previous_report_order: Option, - pub(crate) previous_geometry: Option<(u16, u16)>, - pub(crate) applied_report_order: u64, +impl SurfaceCreationGate { + fn begin(&self) -> anyhow::Result> { + let thread = std::thread::current().id(); + let mut state = self.state.lock().unwrap(); + if let Some(depth) = state.active_threads.get_mut(&thread) { + *depth = depth.saturating_add(1); + } else { + if state.shutting_down { + anyhow::bail!("server is shutting down"); + } + state.active_threads.insert(thread, 1); + } + Ok(SurfaceCreationGuard { gate: self, thread }) + } + + fn stop(&self) { + let mut state = self.state.lock().unwrap(); + state.shutting_down = true; + self.idle.notify_all(); + } + + fn stop_and_wait_until(&self, deadline: Instant) -> bool { + let mut state = self.state.lock().unwrap(); + state.shutting_down = true; + while !state.active_threads.is_empty() { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.idle.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && !state.active_threads.is_empty() { + return false; + } + } + true + } } -pub(crate) struct ControlClientResize { - pub accepted: bool, - pub reservation_id: Option, - pub effective_size: Option<(u16, u16)>, - pub attached: Option, - pub rollback: ClientSizeRollback, +struct SurfaceCreationGuard<'a> { + gate: &'a SurfaceCreationGate, + thread: ThreadId, } -#[derive(Default)] -struct SurfaceClientSizing { - excluded_clients: HashSet, - exclusive_client: Option, +impl Drop for SurfaceCreationGuard<'_> { + fn drop(&mut self) { + let mut state = self.gate.state.lock().unwrap(); + let remove = { + let depth = + state.active_threads.get_mut(&self.thread).expect("surface creation guard tracked"); + *depth -= 1; + *depth == 0 + }; + if remove { + state.active_threads.remove(&self.thread); + } + if state.active_threads.is_empty() { + self.gate.idle.notify_all(); + } + } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct TerminalGeometryAuthority { - placement: SurfaceId, - client: u64, +struct SurfaceOwnerReservation<'a> { + mux: &'a Mux, + active: bool, +} + +impl SurfaceOwnerReservation<'_> { + fn release(&mut self) { + if self.active { + let previous = self.mux.surface_owner_reservations.fetch_sub(1, Ordering::AcqRel); + debug_assert!(previous > 0, "surface owner reservation accounting underflowed"); + self.active = false; + } + } +} + +impl Drop for SurfaceOwnerReservation<'_> { + fn drop(&mut self) { + self.release(); + } } #[derive(Default)] -struct ClientSizingState { - surfaces: ClientSurfaceSizes, - report_order: HashMap<(SurfaceId, u64), u64>, - latest_explicit_size: Option<(u64, (u16, u16))>, - next_size_order: u64, - policies: HashMap, - terminal_authorities: HashMap, - terminal_runtime_by_placement: HashMap, +struct ShutdownCoordinatorState { + held: bool, + poisoned: bool, } -impl ClientSizingState { - fn next_size_order(&mut self) -> u64 { - self.next_size_order = self.next_size_order.wrapping_add(1).max(1); - self.next_size_order +#[derive(Default)] +struct ShutdownCoordinator { + state: Mutex, + available: Condvar, +} + +impl ShutdownCoordinator { + fn lock_until(&self, deadline: Instant) -> anyhow::Result> { + let mut state = self + .state + .lock() + .map_err(|_| anyhow::anyhow!("shutdown coordinator is unavailable"))?; + loop { + if state.poisoned { + anyhow::bail!("shutdown coordinator is unavailable"); + } + if !state.held { + state.held = true; + return Ok(ShutdownCoordinatorGuard { coordinator: self }); + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + anyhow::bail!("another shutdown request exceeded the shutdown deadline"); + }; + let (next, timeout) = self + .available + .wait_timeout(state, remaining) + .map_err(|_| anyhow::anyhow!("shutdown coordinator is unavailable"))?; + state = next; + if timeout.timed_out() && state.held { + anyhow::bail!("another shutdown request exceeded the shutdown deadline"); + } + } } +} - fn record_explicit_size(&mut self, size: (u16, u16)) { - let order = self.next_size_order(); - self.latest_explicit_size = Some((order, size)); +struct ShutdownCoordinatorGuard<'a> { + coordinator: &'a ShutdownCoordinator, +} + +impl Drop for ShutdownCoordinatorGuard<'_> { + fn drop(&mut self) { + let mut state = self.coordinator.state.lock().unwrap_or_else(PoisonError::into_inner); + state.poisoned |= std::thread::panicking(); + state.held = false; + self.coordinator.available.notify_one(); } +} - fn rollback_token( - &self, - surface: SurfaceId, - attached_clients: Option<&HashSet>, - ) -> ClientSizingRollbackToken { - let participating_surface_clients = self - .surfaces - .get(&surface) - .into_iter() - .flat_map(HashMap::keys) - .filter(|client| self.client_participates(surface, **client)) - .copied() - .collect(); - ClientSizingRollbackToken { - surface_sizes: self.surfaces.get(&surface).cloned(), - surface_orders: self - .report_order - .iter() - .filter_map(|((reported_surface, client), order)| { - (*reported_surface == surface).then_some((*client, *order)) - }) - .collect(), - participating_surface_clients, - uses_excluded_fallback: self.uses_excluded_fallback(surface, attached_clients), +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum ShutdownRequestWatchState { + #[default] + Pending, + Requested, + Cancelled, +} + +#[derive(Default)] +struct ShutdownRequestWatchInner { + state: Mutex, + changed: Condvar, +} + +impl ShutdownRequestWatchInner { + fn transition(&self, next: ShutdownRequestWatchState) { + let mut state = self.state.lock().unwrap(); + if *state == ShutdownRequestWatchState::Pending { + *state = next; + self.changed.notify_all(); } } +} - fn client_participates(&self, surface: SurfaceId, client: u64) -> bool { - let Some(policy) = self.policies.get(&surface) else { - return true; - }; - policy.exclusive_client.map_or_else( - || !policy.excluded_clients.contains(&client), - |exclusive| exclusive == client, - ) +/// One-shot notification that the server process has accepted an exit request. +#[derive(Clone)] +pub struct ShutdownRequestWatch { + inner: Arc, +} + +impl ShutdownRequestWatch { + /// Block until shutdown is requested or this watch is cancelled. + pub fn wait(&self) -> bool { + let state = self.inner.state.lock().unwrap(); + let state = self + .inner + .changed + .wait_while(state, |state| *state == ShutdownRequestWatchState::Pending) + .unwrap(); + *state == ShutdownRequestWatchState::Requested } - fn owns_terminal_geometry(&self, runtime: SurfaceId, surface: SurfaceId, client: u64) -> bool { - self.terminal_authorities - .get(&runtime) - .is_some_and(|authority| authority.placement == surface && authority.client == client) + /// Wake a waiter that no longer needs to observe shutdown. + pub fn cancel(&self) { + self.inner.transition(ShutdownRequestWatchState::Cancelled); } - fn report_participates(&self, surface: SurfaceId, client: u64) -> bool { - if let Some(runtime) = self.terminal_runtime_by_placement.get(&surface) { - return self.owns_terminal_geometry(*runtime, surface, client); + fn request(&self) { + self.inner.transition(ShutdownRequestWatchState::Requested); + } +} + +#[derive(Default)] +struct AsyncSurfaceCreationState { + shutting_down: bool, + active: usize, +} + +#[derive(Default)] +struct AsyncSurfaceCreationGate { + inner: Arc, +} + +#[derive(Default)] +struct AsyncSurfaceCreationGateInner { + state: Mutex, + idle: Condvar, +} + +impl AsyncSurfaceCreationGate { + fn begin(&self) -> anyhow::Result { + let mut state = self.inner.state.lock().unwrap(); + if state.shutting_down { + anyhow::bail!("server is shutting down"); } - self.client_participates(surface, client) + state.active = state.active.saturating_add(1); + Ok(AsyncSurfaceCreationGuard { gate: self.inner.clone() }) } - fn uses_excluded_fallback( - &self, - surface: SurfaceId, - attached_clients: Option<&HashSet>, - ) -> bool { - let attached_participates = attached_clients.is_some_and(|clients| { - clients.iter().any(|client| self.client_participates(surface, *client)) - }); - let reporter_participates = self.surfaces.get(&surface).is_some_and(|viewers| { - viewers.keys().any(|client| self.client_participates(surface, *client)) - }); - !attached_participates && !reporter_participates + fn stop(&self) { + let mut state = self.inner.state.lock().unwrap(); + state.shutting_down = true; + self.inner.idle.notify_all(); } - fn effective_size(&self, surface: SurfaceId, use_excluded: bool) -> Option<(u16, u16)> { - self.surfaces - .get(&surface)? - .iter() - .filter(|(client, _)| use_excluded || self.client_participates(surface, **client)) - .map(|(_, size)| *size) - .reduce(|smallest, size| (smallest.0.min(size.0), smallest.1.min(size.1))) + fn stop_and_wait_until(&self, deadline: Instant) -> bool { + let mut state = self.inner.state.lock().unwrap(); + state.shutting_down = true; + self.inner.idle.notify_all(); + while state.active != 0 { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.inner.idle.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && state.active != 0 { + return false; + } + } + true } - fn latest_effective_size( - &self, - attached_clients: &HashMap>, - ) -> Option<(u64, (u16, u16))> { - // The default for a newly created surface follows the latest - // authoritative terminal report or the latest effective browser - // report. Passive terminal viewports never influence future PTYs. - // Cache browser fallback once per surface to keep this scan linear. - let mut fallback_by_surface = HashMap::::new(); - let ((surface, _), order) = self - .report_order - .iter() - .filter(|((surface, client), _)| { - let surface = *surface; - let client = *client; - if let Some(runtime) = self.terminal_runtime_by_placement.get(&surface) { - return self.owns_terminal_geometry(*runtime, surface, client) - && self - .surfaces - .get(&surface) - .is_some_and(|viewers| viewers.contains_key(&client)); - } - let use_excluded = *fallback_by_surface.entry(surface).or_insert_with(|| { - self.uses_excluded_fallback(surface, attached_clients.get(&surface)) - }); - self.surfaces.get(&surface).is_some_and(|viewers| viewers.contains_key(&client)) - && (use_excluded || self.client_participates(surface, client)) - }) - .max_by_key(|(_, order)| *order) - .map(|(key, order)| (*key, *order))?; - let size = if let Some(runtime) = self.terminal_runtime_by_placement.get(&surface) { - let authority = self.terminal_authorities.get(runtime)?; - self.surfaces.get(&surface).and_then(|viewers| viewers.get(&authority.client)).copied() - } else { - let use_excluded = fallback_by_surface[&surface]; - self.effective_size(surface, use_excluded) - }?; - Some((order, size)) - } - - fn creation_size( - &mut self, - attached_clients: &HashMap>, - ) -> Option<(u16, u16)> { - let report = self.latest_effective_size(attached_clients); - match (self.latest_explicit_size, report) { - (Some((explicit_order, explicit)), Some((report_order, _))) - if explicit_order >= report_order => - { - Some(explicit) - } - (_, Some((_, report))) => { - self.latest_explicit_size = None; - Some(report) + #[cfg(test)] + fn wait_until_stopping(&self, deadline: Instant) -> bool { + let mut state = self.inner.state.lock().unwrap(); + while !state.shutting_down { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.inner.idle.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && !state.shutting_down { + return false; } - (Some((_, explicit)), None) => Some(explicit), - (None, None) => None, } + true } - fn note_applied_report( - &mut self, - surface: SurfaceId, - client: u64, - attached_clients: &HashSet, - effective: Option<(u16, u16)>, - report_order: u64, - ) { - let use_excluded = self.uses_excluded_fallback(surface, Some(attached_clients)); - let contributes = use_excluded || self.client_participates(surface, client); - if effective.is_some() - && contributes - && self - .latest_explicit_size - .is_some_and(|(explicit_order, _)| report_order > explicit_order) - { - self.latest_explicit_size = None; + #[cfg(test)] + fn wait_until_idle(&self, deadline: Instant) -> bool { + let mut state = self.inner.state.lock().unwrap(); + while state.active != 0 { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.inner.idle.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && state.active != 0 { + return false; + } } + true } } -#[cfg(test)] -type CellPixelBeforePublishHook = Arc; -#[cfg(test)] -type CellPixelOperationHook = - Arc, (u16, u16), Instant) -> anyhow::Result> + Send + Sync>; -#[cfg(test)] -type KittyImageBudgetOperationHook = - Arc, KittyGraphicsLimits, Instant) -> anyhow::Result<()> + Send + Sync>; -#[cfg(test)] -type TerminalSpawnAfterCellPixelSnapshotHook = Arc; - -type CellPixelSurfaceResult = (SurfaceId, (u16, u16), anyhow::Result>, bool); - -struct PendingCellPixelOperation { - surface: Weak, - result: DeadlinePending, -} - -struct CellPixelRetryTask { - surfaces: Vec>, - pending: Vec, - attempts: u8, - generation: u64, - target: (u16, u16), - completion: Arc, - report: SurfaceResizeReporter, - timeout: Duration, - #[cfg(test)] - operation_hook: Option, -} - -#[derive(Default)] -struct CellPixelRetryQueue { - pending: Option, - worker_running: bool, +struct AsyncSurfaceCreationGuard { + gate: Arc, } -fn apply_cell_pixel_size_until( - surface: &Arc, - target: (u16, u16), - deadline: Instant, - report: &SurfaceResizeReporter, - #[cfg(test)] operation_hook: Option<&CellPixelOperationHook>, -) -> CellPixelSurfaceResult { - let id = surface.id; - let size = surface.size(); - let callback = report.clone(); - #[cfg(test)] - if let Some(hook) = operation_hook { - let result = - validate_cell_pixel_convergence(surface, target, hook(surface, target, deadline)); - callback(id, size, result.as_ref().ok().copied().flatten()); - let deferred = result.as_ref().err().is_some_and(|error| { - error.downcast_ref::().is_some() - }); - return (id, size, result, deferred); +impl Drop for AsyncSurfaceCreationGuard { + fn drop(&mut self) { + let mut state = self.gate.state.lock().unwrap(); + state.active = state.active.checked_sub(1).expect("async surface creation guard tracked"); + if state.active == 0 { + self.gate.idle.notify_all(); + } } - let result = validate_cell_pixel_convergence( - surface, - target, - surface.set_cell_pixel_size_reporting_until( - target.0, - target.1, - deadline, - Box::new(move |accepted| callback(id, size, accepted)), - ), - ); - let deferred = result.as_ref().err().is_some_and(|error| { - error.downcast_ref::().is_some() - }); - (id, size, result, deferred) } -fn validate_cell_pixel_convergence( - surface: &Surface, - target: (u16, u16), - result: anyhow::Result>, -) -> anyhow::Result> { - let reservation = result?; - if reservation.is_none() && surface.cell_pixel_size() != target { - anyhow::bail!("cell pixel update did not converge to {}x{} pixels", target.0, target.1); - } - Ok(reservation) +#[derive(Default)] +enum BrowserRuntimeSlotState { + #[default] + Empty, + Connecting, + Ready(Arc), + Stopping(Option>), } -/// One-shot wakeup shared by a terminal-exit subscription and any -/// connection-owned cancellation sources. The durable terminal registry -/// remains authoritative; this only decides when a waiter should query it. -pub(crate) struct ResourceWaitWake { - notified: Mutex, +#[derive(Default)] +struct BrowserRuntimeSlot { + state: Mutex, changed: Condvar, } -impl Default for ResourceWaitWake { - fn default() -> Self { - Self { notified: Mutex::new(false), changed: Condvar::new() } +impl BrowserRuntimeSlot { + fn get_or_connect( + &self, + is_shutting_down: impl Fn() -> bool, + can_reuse: impl Fn(&BrowserRuntime) -> bool, + connect: impl FnOnce() -> anyhow::Result>, + ) -> anyhow::Result> { + loop { + let mut state = self.state.lock().unwrap(); + if is_shutting_down() { + anyhow::bail!("server is shutting down"); + } + match &*state { + BrowserRuntimeSlotState::Ready(runtime) if can_reuse(runtime) => { + return Ok(runtime.clone()); + } + BrowserRuntimeSlotState::Ready(_) => { + *state = BrowserRuntimeSlotState::Empty; + } + BrowserRuntimeSlotState::Empty => { + *state = BrowserRuntimeSlotState::Connecting; + break; + } + BrowserRuntimeSlotState::Connecting => { + drop(self.changed.wait(state).unwrap()); + } + BrowserRuntimeSlotState::Stopping(_) => { + anyhow::bail!("server is shutting down"); + } + } + } + + // The connector can block on process or network work. The slot owner + // has already published Connecting and released its state lock here. + let connected = connect(); + let mut state = self.state.lock().unwrap(); + // This read belongs to the Connecting -> Ready transition. Shutdown + // can start while the connector runs without holding this lock. + let shutting_down = is_shutting_down(); + match connected { + Ok(created) + if matches!(*state, BrowserRuntimeSlotState::Connecting) && !shutting_down => + { + *state = BrowserRuntimeSlotState::Ready(created.clone()); + self.changed.notify_all(); + Ok(created) + } + Ok(created) => { + self.changed.notify_all(); + drop(state); + created.shutdown(); + anyhow::bail!("server is shutting down"); + } + Err(error) => { + let stopping = + shutting_down || matches!(*state, BrowserRuntimeSlotState::Stopping(_)); + if matches!(*state, BrowserRuntimeSlotState::Connecting) { + *state = BrowserRuntimeSlotState::Empty; + } + self.changed.notify_all(); + drop(state); + if stopping { + anyhow::bail!("server is shutting down"); + } + Err(error) + } + } } -} -impl ResourceWaitWake { - pub(crate) fn notify(&self) { - let mut notified = self.notified.lock().unwrap(); - *notified = true; - self.changed.notify_all(); + fn stop(&self) { + self.stop_with_fence(|| {}); } - /// Returns true for an explicit wake and false when the deadline expires. - pub(crate) fn wait_until(&self, deadline: Option) -> bool { - let mut notified = self.notified.lock().unwrap(); - while !*notified { - match deadline { - Some(deadline) => { - let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { - return false; - }; - let (next, timeout) = self.changed.wait_timeout(notified, remaining).unwrap(); - notified = next; - if timeout.timed_out() && !*notified { - return false; - } - } - None => notified = self.changed.wait(notified).unwrap(), + fn stop_with_fence(&self, fence: impl FnOnce()) { + let mut state = self.state.lock().unwrap(); + // The global shutdown fence and this owner transition share one + // critical section, so a connector publishes before both or after + // neither. There is no state where shutdown is fenced but this slot + // can still publish Ready. + fence(); + let current = std::mem::take(&mut *state); + *state = match current { + BrowserRuntimeSlotState::Ready(runtime) => { + BrowserRuntimeSlotState::Stopping(Some(runtime)) + } + BrowserRuntimeSlotState::Stopping(runtime) => { + BrowserRuntimeSlotState::Stopping(runtime) + } + BrowserRuntimeSlotState::Empty | BrowserRuntimeSlotState::Connecting => { + BrowserRuntimeSlotState::Stopping(None) } - } - true + }; + self.changed.notify_all(); } -} -#[derive(Default)] -struct TerminalExitDetachTracker { - active: Mutex>, - changed: Condvar, -} + fn take_for_shutdown(&self) -> Option> { + self.stop(); + let mut state = self.state.lock().unwrap(); + let BrowserRuntimeSlotState::Stopping(runtime) = &mut *state else { + unreachable!("stopped browser runtime slot has a stopping state"); + }; + runtime.take() + } -impl TerminalExitDetachTracker { - fn acquire(self: &Arc, terminal_id: String) -> Option { - if !self.active.lock().unwrap().insert(terminal_id.clone()) { - return None; - } - Some(TerminalExitDetachLease { tracker: self.clone(), terminal_id }) + fn restore_for_shutdown(&self, runtime: Arc) { + let mut state = self.state.lock().unwrap(); + let BrowserRuntimeSlotState::Stopping(current) = &mut *state else { + unreachable!("browser runtime can only be restored while stopping"); + }; + debug_assert!(current.is_none(), "browser runtime restored twice"); + *current = Some(runtime); } - fn finish(&self, terminal_id: &str) { - let mut active = self.active.lock().unwrap(); - if active.remove(terminal_id) { - self.changed.notify_all(); + fn take_on_drop(&mut self) -> Option> { + let state = self.state.get_mut().unwrap_or_else(PoisonError::into_inner); + match std::mem::take(state) { + BrowserRuntimeSlotState::Ready(runtime) => Some(runtime), + BrowserRuntimeSlotState::Stopping(runtime) => runtime, + BrowserRuntimeSlotState::Empty | BrowserRuntimeSlotState::Connecting => None, } } #[cfg(test)] - fn contains(&self, terminal_id: &str) -> bool { - self.active.lock().unwrap().contains(terminal_id) + fn install_for_test(&self, runtime: Arc) { + *self.state.lock().unwrap() = BrowserRuntimeSlotState::Ready(runtime); } #[cfg(test)] - fn wait_until_finished(&self, terminal_id: &str, deadline: Instant) -> bool { - let mut active = self.active.lock().unwrap(); - while active.contains(terminal_id) { - let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { - return false; - }; - let (next, timeout) = self.changed.wait_timeout(active, remaining).unwrap(); - active = next; - if timeout.timed_out() && active.contains(terminal_id) { - return false; - } + fn has_runtime_for_test(&self) -> bool { + match &*self.state.lock().unwrap() { + BrowserRuntimeSlotState::Ready(_) | BrowserRuntimeSlotState::Stopping(Some(_)) => true, + BrowserRuntimeSlotState::Empty + | BrowserRuntimeSlotState::Connecting + | BrowserRuntimeSlotState::Stopping(None) => false, } - true } -} - -struct TerminalExitDetachLease { - tracker: Arc, - terminal_id: String, -} -impl Drop for TerminalExitDetachLease { - fn drop(&mut self) { - self.tracker.finish(&self.terminal_id); + #[cfg(test)] + fn lock_available_for_test(&self) -> bool { + self.state.try_lock().is_ok() } } -#[derive(Default)] -struct TerminalExitWaiters { - next_id: AtomicU64, - waiters: Mutex>>>, +enum BrowserSurfaceBootstrap { + ExistingTarget { runtime: Arc, target_id: String, url: String }, + Provider { tab_id: TabPublicId, url: String }, } -pub(crate) struct TerminalExitSubscription<'a> { - owner: &'a TerminalExitWaiters, - terminal_id: TerminalPublicId, - waiter_id: u64, - wake: Arc, +enum SurfaceResizeRestore { + Complete(bool), + Pending(Receiver), } -impl TerminalExitWaiters { - fn subscribe(&self, terminal_id: &TerminalPublicId) -> TerminalExitSubscription<'_> { - let waiter_id = self.next_id.fetch_add(1, Ordering::Relaxed); - let wake = Arc::new(ResourceWaitWake::default()); - self.waiters - .lock() - .unwrap() - .entry(terminal_id.clone()) - .or_default() - .insert(waiter_id, Arc::downgrade(&wake)); - TerminalExitSubscription { owner: self, terminal_id: terminal_id.clone(), waiter_id, wake } - } +#[derive(PartialEq, Eq)] +struct ClientSizingRollbackToken { + surface_sizes: Option>, + surface_orders: HashMap, + participating_surface_clients: HashSet, + uses_excluded_fallback: bool, +} - fn notify(&self, terminal_id: &TerminalPublicId) { - let waiters = self.waiters.lock().unwrap().remove(terminal_id).unwrap_or_default(); - for waiter in waiters.into_values().filter_map(|waiter| waiter.upgrade()) { - waiter.notify(); - } - } +#[derive(Clone, Copy)] +pub(crate) struct ClientSizeRollback { + pub(crate) previous_size: Option<(u16, u16)>, + pub(crate) previous_report_order: Option, + pub(crate) previous_geometry: Option<(u16, u16)>, + pub(crate) applied_report_order: u64, +} - #[cfg(test)] - fn waiter_count(&self, terminal_id: &TerminalPublicId) -> usize { - self.waiters.lock().unwrap().get(terminal_id).map(HashMap::len).unwrap_or_default() - } +pub(crate) struct ControlClientResize { + pub accepted: bool, + pub reservation_id: Option, + pub effective_size: Option<(u16, u16)>, + pub attached: Option, + pub rollback: ClientSizeRollback, } -impl TerminalExitSubscription<'_> { - pub(crate) fn wake(&self) -> Arc { - self.wake.clone() +#[derive(Default)] +struct SurfaceClientSizing { + excluded_clients: HashSet, + exclusive_client: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TerminalGeometryAuthority { + placement: SurfaceId, + client: u64, +} + +#[derive(Default)] +struct ClientSizingState { + surfaces: ClientSurfaceSizes, + report_order: HashMap<(SurfaceId, u64), u64>, + latest_explicit_size: Option<(u64, (u16, u16))>, + next_size_order: u64, + policies: HashMap, + terminal_authorities: HashMap, + terminal_runtime_by_placement: HashMap, +} + +impl ClientSizingState { + fn next_size_order(&mut self) -> u64 { + self.next_size_order = self.next_size_order.wrapping_add(1).max(1); + self.next_size_order } - pub(crate) fn wait_until(&self, deadline: Option) -> bool { - self.wake.wait_until(deadline) + fn record_explicit_size(&mut self, size: (u16, u16)) { + let order = self.next_size_order(); + self.latest_explicit_size = Some((order, size)); } -} -impl Drop for TerminalExitSubscription<'_> { - fn drop(&mut self) { - let mut waiters = self.owner.waiters.lock().unwrap(); - let remove_terminal = waiters.get_mut(&self.terminal_id).is_some_and(|terminal_waiters| { - terminal_waiters.remove(&self.waiter_id); - terminal_waiters.is_empty() - }); - if remove_terminal { - waiters.remove(&self.terminal_id); + fn rollback_token( + &self, + surface: SurfaceId, + attached_clients: Option<&HashSet>, + ) -> ClientSizingRollbackToken { + let participating_surface_clients = self + .surfaces + .get(&surface) + .into_iter() + .flat_map(HashMap::keys) + .filter(|client| self.client_participates(surface, **client)) + .copied() + .collect(); + ClientSizingRollbackToken { + surface_sizes: self.surfaces.get(&surface).cloned(), + surface_orders: self + .report_order + .iter() + .filter_map(|((reported_surface, client), order)| { + (*reported_surface == surface).then_some((*client, *order)) + }) + .collect(), + participating_surface_clients, + uses_excluded_fallback: self.uses_excluded_fallback(surface, attached_clients), } } -} -#[cfg(test)] -struct TerminalExitStateQueryGuard<'a>(&'a AtomicU64); + fn client_participates(&self, surface: SurfaceId, client: u64) -> bool { + let Some(policy) = self.policies.get(&surface) else { + return true; + }; + policy.exclusive_client.map_or_else( + || !policy.excluded_clients.contains(&client), + |exclusive| exclusive == client, + ) + } -#[cfg(test)] -impl Drop for TerminalExitStateQueryGuard<'_> { - fn drop(&mut self) { - // Count completed queries. Tests use this release/acquire edge to - // distinguish a waiter blocked after its initial read from one that - // merely entered terminal_exit_state and is still behind the registry - // writer lock. - self.0.fetch_add(1, Ordering::Release); + fn owns_terminal_geometry(&self, runtime: SurfaceId, surface: SurfaceId, client: u64) -> bool { + self.terminal_authorities + .get(&runtime) + .is_some_and(|authority| authority.placement == surface && authority.client == client) } -} -/// The multiplexer. Shared by frontends and the control socket server. -#[derive(Default)] -struct ConfigReloadState { - requested: u64, - applied: u64, -} + fn report_participates(&self, surface: SurfaceId, client: u64) -> bool { + if let Some(runtime) = self.terminal_runtime_by_placement.get(&surface) { + return self.owns_terminal_geometry(*runtime, surface, client); + } + self.client_participates(surface, client) + } -/// Describes why an owner did not confirm a requested configuration reload. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ConfigReloadError { - /// The owner stopped before it could apply the request. - OwnerStopped, - /// The owner did not confirm the request before the failure deadline. - TimedOut, -} + fn uses_excluded_fallback( + &self, + surface: SurfaceId, + attached_clients: Option<&HashSet>, + ) -> bool { + let attached_participates = attached_clients.is_some_and(|clients| { + clients.iter().any(|client| self.client_participates(surface, *client)) + }); + let reporter_participates = self.surfaces.get(&surface).is_some_and(|viewers| { + viewers.keys().any(|client| self.client_participates(surface, *client)) + }); + !attached_participates && !reporter_participates + } -impl fmt::Display for ConfigReloadError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::OwnerStopped => "configuration reload owner stopped before applying the request", - Self::TimedOut => "configuration reload owner did not apply the request", - }) + fn effective_size(&self, surface: SurfaceId, use_excluded: bool) -> Option<(u16, u16)> { + self.surfaces + .get(&surface)? + .iter() + .filter(|(client, _)| use_excluded || self.client_participates(surface, **client)) + .map(|(_, size)| *size) + .reduce(|smallest, size| (smallest.0.min(size.0), smallest.1.min(size.1))) } -} -impl std::error::Error for ConfigReloadError {} + fn latest_effective_size( + &self, + attached_clients: &HashMap>, + ) -> Option<(u64, (u16, u16))> { + // The default for a newly created surface follows the latest + // authoritative terminal report or the latest effective browser + // report. Passive terminal viewports never influence future PTYs. + // Cache browser fallback once per surface to keep this scan linear. + let mut fallback_by_surface = HashMap::::new(); + let ((surface, _), order) = self + .report_order + .iter() + .filter(|((surface, client), _)| { + let surface = *surface; + let client = *client; + if let Some(runtime) = self.terminal_runtime_by_placement.get(&surface) { + return self.owns_terminal_geometry(*runtime, surface, client) + && self + .surfaces + .get(&surface) + .is_some_and(|viewers| viewers.contains_key(&client)); + } + let use_excluded = *fallback_by_surface.entry(surface).or_insert_with(|| { + self.uses_excluded_fallback(surface, attached_clients.get(&surface)) + }); + self.surfaces.get(&surface).is_some_and(|viewers| viewers.contains_key(&client)) + && (use_excluded || self.client_participates(surface, client)) + }) + .max_by_key(|(_, order)| *order) + .map(|(key, order)| (*key, *order))?; + let size = if let Some(runtime) = self.terminal_runtime_by_placement.get(&surface) { + let authority = self.terminal_authorities.get(runtime)?; + self.surfaces.get(&surface).and_then(|viewers| viewers.get(&authority.client)).copied() + } else { + let use_excluded = fallback_by_surface[&surface]; + self.effective_size(surface, use_excluded) + }?; + Some((order, size)) + } -pub struct Mux { - /// Serializes durable workspace commits, their in-memory projection, and - /// publication of revisioned workspace deltas. Lock order is always - /// registry, then state. - workspace_registry: SignaledMutex, - session_public_id: SessionPublicId, - state: Mutex, - subscribers: MuxEventBroadcaster, - config_reload: Mutex, - config_reload_changed: Condvar, - next_id: AtomicU64, - next_notification_id: AtomicU64, - next_active_at: AtomicU64, - next_in_process_resize_owner: AtomicU64, - surface_options: Mutex, - provider_workspace: Mutex, - workspace_lifecycles: Mutex>>>, - pending_workspace_surfaces: Mutex>, - client_sizing_lifecycle: Mutex<()>, - client_sizing: Mutex, - #[cfg(test)] - client_resize_before_apply: Mutex>>, - #[cfg(test)] - terminal_move_before_projection: Mutex>>, - #[cfg(test)] - client_rollback_before_wait: Mutex>>, - #[cfg(test)] - workspace_close_before_empty_check: Mutex>>, - #[cfg(test)] - workspace_close_after_selector_resolution: Mutex>>, - #[cfg(test)] - workspace_delta_before_emit: Mutex>, - #[cfg(test)] - resource_rename_after_selector_resolution: Mutex>, - #[cfg(test)] - layout_apply_after_workspace_reservation: Mutex>>, - #[cfg(test)] - terminal_create_after_empty_check: Mutex>>, - #[cfg(test)] - terminal_create_after_materialization_lock: Mutex>>, - #[cfg(test)] - terminal_create_after_workspace_reservation: Mutex>>, - #[cfg(test)] - terminal_spawn_after_cell_pixel_snapshot: - Mutex>, - #[cfg(test)] - terminal_create_after_terminal_reservation: Mutex>, - pending_terminal_hosts: Mutex>, - reserved_in_process_terminals: Mutex>, - #[cfg(test)] - viewport_split_after_spawn: Mutex>>, - #[cfg(test)] - resource_mutation_metrics: Mutex>, - #[cfg(test)] - resource_projection_before_commit: Mutex>>, - #[cfg(test)] - resource_close_after_commit: Mutex>>, - #[cfg(test)] - resource_close_cleanup: Mutex>>, - browser_providers: Arc, - browser_runtime: Mutex>>, - active_render_attachments: Arc, - deadline_fanout_pool: DeadlineFanoutPool, - kitty_image_budget: Mutex, - kitty_image_budget_changed: Condvar, - #[cfg(debug_assertions)] - terminal_host_reconnect_completion_failures: AtomicU64, - #[cfg(debug_assertions)] - terminal_host_test_disconnect_after_spawn_ms: AtomicU64, - #[cfg(test)] - kitty_image_budget_operation: Mutex>, - cell_pixel_lifecycle: Mutex<()>, - next_cell_pixel_generation: AtomicU64, - cell_pixels: Mutex<(u16, u16)>, - pending_cell_pixels: Mutex>, - cell_pixel_retries: Mutex, - #[cfg(test)] - cell_pixel_before_publish: Mutex>, - #[cfg(test)] - cell_pixel_operation: Mutex>, - #[cfg(test)] - cell_pixel_fanout_timeout: Mutex>, - default_colors: Mutex, - durable_terminal_defaults: AtomicBool, - sidebar_plugin: Mutex, - agent_records: Mutex>, - /// Nonterminal notifications remain placement-local. Terminal unread - /// state is keyed separately by stable content identity so every view of - /// one terminal shares the same attention marker. - placement_notifications: Mutex>, - terminal_notifications: Mutex>, - notification_ledger: Mutex>, - resource_machine_service: OnceLock>, - journal_kernel: Arc, - journal_ingress: crate::journal_ingress::JournalIngressSender, - journal_hook_dispatcher_started: AtomicBool, - journal_hook_runtime: Arc, - /// Wake-only signal for durable journal subscribers. Consumers always - /// reread SQLite by cursor, so missed or coalesced notifications are safe. - journal_event_epoch: Mutex, - journal_event_changed: Condvar, - #[cfg(test)] - journal_segment_prepare_hook: Mutex>>, - terminal_exit_waiters: TerminalExitWaiters, - #[cfg(test)] - terminal_exit_state_queries: AtomicU64, - /// Keeps a close from removing a just-created surface before legacy - /// callers have resolved the committed public result back to its runtime. - resource_creation_handoff: Mutex<()>, - resource_creation_execution: Mutex<()>, - resource_creation_active: AtomicBool, - terminal_adoptions: Mutex>, - terminal_exit_detaches: Arc, - terminal_adoption_insert_failures: AtomicU64, - server_lifecycle_ready: AtomicBool, - shutting_down: AtomicBool, - pub(crate) control_clients: crate::server::ClientRegistry, - pub(crate) surface_operation_admission: Arc, - pairing: PairingBroker, - #[cfg(test)] - test_surface_runtime: bool, - pub session: String, + fn creation_size( + &mut self, + attached_clients: &HashMap>, + ) -> Option<(u16, u16)> { + let report = self.latest_effective_size(attached_clients); + match (self.latest_explicit_size, report) { + (Some((explicit_order, explicit)), Some((report_order, _))) + if explicit_order >= report_order => + { + Some(explicit) + } + (_, Some((_, report))) => { + self.latest_explicit_size = None; + Some(report) + } + (Some((_, explicit)), None) => Some(explicit), + (None, None) => None, + } + } + + fn note_applied_report( + &mut self, + surface: SurfaceId, + client: u64, + attached_clients: &HashSet, + effective: Option<(u16, u16)>, + report_order: u64, + ) { + let use_excluded = self.uses_excluded_fallback(surface, Some(attached_clients)); + let contributes = use_excluded || self.client_participates(surface, client); + if effective.is_some() + && contributes + && self + .latest_explicit_size + .is_some_and(|(explicit_order, _)| report_order > explicit_order) + { + self.latest_explicit_size = None; + } + } } -#[derive(Clone)] -struct RestoredResourceContent { - slot: SurfaceId, - identity: TabResourceIdentity, - name: Option, - browser: Option, +#[cfg(test)] +type CellPixelBeforePublishHook = Arc; +#[cfg(test)] +type CellPixelOperationHook = + Arc, (u16, u16), Instant) -> anyhow::Result> + Send + Sync>; +#[cfg(test)] +type KittyImageBudgetOperationHook = + Arc, KittyGraphicsLimits, Instant) -> anyhow::Result<()> + Send + Sync>; +#[cfg(test)] +type TerminalSpawnAfterCellPixelSnapshotHook = Arc; + +type CellPixelSurfaceResult = (SurfaceId, (u16, u16), anyhow::Result>, bool); + +struct PendingCellPixelOperation { + surface: Weak, + result: DeadlinePending, } -struct RestoredResourceState { - state: State, - next_id: u64, - contents: Vec, +struct CellPixelRetryTask { + surfaces: Vec>, + pending: Vec, + attempts: u8, + generation: u64, + target: (u16, u16), + completion: Arc, + report: SurfaceResizeReporter, + timeout: Duration, + #[cfg(test)] + operation_hook: Option, } -#[cfg(unix)] -struct RestoredTerminalBinding { - public_id: TerminalPublicId, - placements: Vec<(SurfaceId, TabResourceIdentity)>, +#[derive(Default)] +struct CellPixelRetryQueue { + pending: Option, + worker_running: bool, } -impl Mux { - fn default_workspace_name(state: &State) -> String { - state.workspaces.len().to_string() +fn apply_cell_pixel_size_until( + surface: &Arc, + target: (u16, u16), + deadline: Instant, + report: &SurfaceResizeReporter, + #[cfg(test)] operation_hook: Option<&CellPixelOperationHook>, +) -> CellPixelSurfaceResult { + let id = surface.id; + let size = surface.size(); + let callback = report.clone(); + #[cfg(test)] + if let Some(hook) = operation_hook { + let result = + validate_cell_pixel_convergence(surface, target, hook(surface, target, deadline)); + callback(id, size, result.as_ref().ok().copied().flatten()); + let deferred = result.as_ref().err().is_some_and(|error| { + error.downcast_ref::().is_some() + }); + return (id, size, result, deferred); } + let result = validate_cell_pixel_convergence( + surface, + target, + surface.set_cell_pixel_size_reporting_until( + target.0, + target.1, + deadline, + Box::new(move |accepted| callback(id, size, accepted)), + ), + ); + let deferred = result.as_ref().err().is_some_and(|error| { + error.downcast_ref::().is_some() + }); + (id, size, result, deferred) +} - /// Resolve one public resource path from a single live-state snapshot. - /// Direct content IDs use the reverse resource indexes and never trigger - /// a registry snapshot or process query. - pub fn resolve_resource_path( - &self, - target: crate::ResourceTarget, - selectors: &crate::ResourceSelectors, - ) -> Result { - let registry = self.workspace_registry.lock().unwrap(); - let state = self.state.lock().unwrap(); - resolve_resource_selectors( - &state, - ResourceSelectorContext { - machine_id: registry.machine_id(), - machine_name: None, - session_id: registry.session_id(), - session_name: &self.session, - }, - target, - selectors, - ) - .map(|resolved| resolved.path) +fn validate_cell_pixel_convergence( + surface: &Surface, + target: (u16, u16), + result: anyhow::Result>, +) -> anyhow::Result> { + let reservation = result?; + if reservation.is_none() && surface.cell_pixel_size() != target { + anyhow::bail!("cell pixel update did not converge to {}x{} pixels", target.0, target.1); } + Ok(reservation) +} - fn resolve_resource_path_in_state( - &self, - state: &State, - registry: &WorkspaceRegistry, - target: crate::ResourceTarget, - selectors: &crate::ResourceSelectors, - ) -> Result { - resolve_resource_selectors( - state, - ResourceSelectorContext { - machine_id: registry.machine_id(), - machine_name: None, - session_id: registry.session_id(), - session_name: &self.session, - }, - target, - selectors, - ) +/// One-shot wakeup shared by a terminal-exit subscription and any +/// connection-owned cancellation sources. The durable terminal registry +/// remains authoritative; this only decides when a waiter should query it. +pub(crate) struct ResourceWaitWake { + notified: Mutex, + changed: Condvar, +} + +impl Default for ResourceWaitWake { + fn default() -> Self { + Self { notified: Mutex::new(false), changed: Condvar::new() } } +} - pub fn new(session: impl Into, surface_options: SurfaceOptions) -> Arc { - Self::new_with_test_surface_runtime( - session, - surface_options, - ProviderWorkspaceState::default(), - false, - ) +impl ResourceWaitWake { + pub(crate) fn notify(&self) { + let mut notified = self.notified.lock().unwrap(); + *notified = true; + self.changed.notify_all(); } - /// Builds a mux whose workspace lifecycle is provider-owned from its - /// first control connection. The authority must be provisioned by the - /// provider that owns this mux generation. - pub fn new_provider_managed( - session: impl Into, - surface_options: SurfaceOptions, - authority: ProviderWorkspaceAuthority, - ) -> Arc { - Self::new_with_test_surface_runtime( - session, - surface_options, - ProviderWorkspaceState { - managed: true, - mux_generation: None, - authority_generation: 1, - authority: Some(authority), - }, - false, - ) + /// Returns true for an explicit wake and false when the deadline expires. + pub(crate) fn wait_until(&self, deadline: Option) -> bool { + let mut notified = self.notified.lock().unwrap(); + while !*notified { + match deadline { + Some(deadline) => { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.changed.wait_timeout(notified, remaining).unwrap(); + notified = next; + if timeout.timed_out() && !*notified { + return false; + } + } + None => notified = self.changed.wait(notified).unwrap(), + } + } + true } +} - /// Builds a provider-owned mux whose authority will be installed through - /// the root-only management socket before lifecycle mutations are allowed. - pub fn new_provider_managed_pending( - session: impl Into, - surface_options: SurfaceOptions, - mux_generation: impl Into, - ) -> anyhow::Result> { - let mux_generation = mux_generation.into(); - validate_mux_generation(&mux_generation)?; - Ok(Self::new_with_test_surface_runtime( - session, - surface_options, - ProviderWorkspaceState { - managed: true, - mux_generation: Some(mux_generation.into_boxed_str()), - authority_generation: 0, - authority: None, - }, - false, - )) +#[derive(Default)] +struct TerminalExitDetachTracker { + active: Mutex>, + changed: Condvar, +} + +impl TerminalExitDetachTracker { + fn acquire(self: &Arc, terminal_id: String) -> Option { + if !self.active.lock().unwrap().insert(terminal_id.clone()) { + return None; + } + Some(TerminalExitDetachLease { tracker: self.clone(), terminal_id }) } - fn new_with_test_surface_runtime( - session: impl Into, - surface_options: SurfaceOptions, - provider_workspace: ProviderWorkspaceState, - #[cfg_attr(not(test), allow(unused_variables))] test_surface_runtime: bool, - ) -> Arc { - let session = session.into(); - let registry = WorkspaceRegistry::in_memory(&session) - .expect("in-memory workspace registry must initialize"); - Self::from_workspace_registry( - session, - surface_options, - registry, - provider_workspace, - test_surface_runtime, - ) - .expect("in-memory workspace registry must load") + fn finish(&self, terminal_id: &str) { + let mut active = self.active.lock().unwrap(); + if active.remove(terminal_id) { + self.changed.notify_all(); + } } - pub fn open_persistent( - session: impl Into, - surface_options: SurfaceOptions, - state_root: &Path, - ) -> anyhow::Result> { - let session = session.into(); - let registry = WorkspaceRegistry::open(state_root, &session)?; - Self::from_workspace_registry( - session, - surface_options, - registry, - ProviderWorkspaceState::default(), - false, - ) + #[cfg(test)] + fn contains(&self, terminal_id: &str) -> bool { + self.active.lock().unwrap().contains(terminal_id) } - pub fn open_persistent_provider_managed( - session: impl Into, - surface_options: SurfaceOptions, - state_root: &Path, - authority: ProviderWorkspaceAuthority, - ) -> anyhow::Result> { - let session = session.into(); - let registry = WorkspaceRegistry::open(state_root, &session)?; - Self::from_workspace_registry( - session, - surface_options, - registry, - ProviderWorkspaceState { - managed: true, - mux_generation: None, - authority_generation: 1, - authority: Some(authority), - }, - false, - ) + #[cfg(test)] + fn wait_until_finished(&self, terminal_id: &str, deadline: Instant) -> bool { + let mut active = self.active.lock().unwrap(); + while active.contains(terminal_id) { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.changed.wait_timeout(active, remaining).unwrap(); + active = next; + if timeout.timed_out() && active.contains(terminal_id) { + return false; + } + } + true } +} - pub fn open_persistent_provider_managed_pending( - session: impl Into, - surface_options: SurfaceOptions, - state_root: &Path, - mux_generation: impl Into, - ) -> anyhow::Result> { - let mux_generation = mux_generation.into(); - validate_mux_generation(&mux_generation)?; - let session = session.into(); - let registry = WorkspaceRegistry::open(state_root, &session)?; - Self::from_workspace_registry( - session, - surface_options, - registry, - ProviderWorkspaceState { - managed: true, - mux_generation: Some(mux_generation.into_boxed_str()), - authority_generation: 0, - authority: None, - }, - false, - ) +struct TerminalExitDetachLease { + tracker: Arc, + terminal_id: String, +} + +impl Drop for TerminalExitDetachLease { + fn drop(&mut self) { + self.tracker.finish(&self.terminal_id); } +} - fn from_workspace_registry( - session: String, - mut surface_options: SurfaceOptions, - registry: WorkspaceRegistry, - provider_workspace: ProviderWorkspaceState, - #[cfg_attr(not(test), allow(unused_variables))] test_surface_runtime: bool, - ) -> anyhow::Result> { - let snapshot = registry.snapshot()?; - let topology = registry.resource_topology_snapshot()?; - let RestoredResourceState { mut state, next_id, contents } = - restore_resource_state(snapshot, topology)?; - let RestoredPublicProjections { - default_colors, - has_terminal_defaults, - next_notification_id, - agent_records, - terminal_notifications, - notification_ledger, - } = restore_public_projections(&state, registry.public_projections()?)?; - let journal_producers = registry.journal_producer_manifests()?; - let session_public_id = registry.session_id().clone(); - let journal_kernel = crate::journal_kernel::JournalKernel::new( - registry.session_journal_database_path(), - &journal_producers, - )?; - let (journal_ingress, journal_ingress_receiver) = - crate::journal_ingress::JournalIngressSender::new( - registry.session_journal_database_path().is_some(), - ); - surface_options.browser_session_name = session.clone(); - Self::rebuild_split_screen_index(&mut state); - let mux = Arc::new(Mux { - workspace_registry: SignaledMutex::new(registry), - session_public_id, - state: Mutex::new(state), - subscribers: MuxEventBroadcaster::default(), - config_reload: Mutex::new(ConfigReloadState::default()), - config_reload_changed: Condvar::new(), - next_id: AtomicU64::new(next_id), - next_notification_id: AtomicU64::new(next_notification_id), - next_active_at: AtomicU64::new(1), - next_in_process_resize_owner: AtomicU64::new(1), - surface_options: Mutex::new(surface_options), - provider_workspace: Mutex::new(provider_workspace), - workspace_lifecycles: Mutex::new(HashMap::new()), - pending_workspace_surfaces: Mutex::new(HashMap::new()), - client_sizing_lifecycle: Mutex::new(()), - client_sizing: Mutex::new(ClientSizingState::default()), - #[cfg(test)] - client_resize_before_apply: Mutex::new(None), - #[cfg(test)] - terminal_move_before_projection: Mutex::new(None), - #[cfg(test)] - client_rollback_before_wait: Mutex::new(None), - #[cfg(test)] - workspace_close_before_empty_check: Mutex::new(None), - #[cfg(test)] - workspace_close_after_selector_resolution: Mutex::new(None), - #[cfg(test)] - workspace_delta_before_emit: Mutex::new(None), - #[cfg(test)] - resource_rename_after_selector_resolution: Mutex::new(None), - #[cfg(test)] - layout_apply_after_workspace_reservation: Mutex::new(None), - #[cfg(test)] - terminal_create_after_empty_check: Mutex::new(None), - #[cfg(test)] - terminal_create_after_materialization_lock: Mutex::new(None), - #[cfg(test)] - terminal_create_after_workspace_reservation: Mutex::new(None), - #[cfg(test)] - terminal_spawn_after_cell_pixel_snapshot: Mutex::new(None), - #[cfg(test)] - terminal_create_after_terminal_reservation: Mutex::new(None), - pending_terminal_hosts: Mutex::new(HashMap::new()), - reserved_in_process_terminals: Mutex::new(HashMap::new()), - #[cfg(test)] - viewport_split_after_spawn: Mutex::new(None), - #[cfg(test)] - resource_mutation_metrics: Mutex::new(None), - #[cfg(test)] - resource_projection_before_commit: Mutex::new(None), - #[cfg(test)] - resource_close_after_commit: Mutex::new(None), - #[cfg(test)] - resource_close_cleanup: Mutex::new(None), - browser_providers: Arc::new(BrowserProviderRegistry::default()), - browser_runtime: Mutex::new(None), - active_render_attachments: Arc::new(AtomicUsize::new(0)), - deadline_fanout_pool: DeadlineFanoutPool::new(), - kitty_image_budget: Mutex::new(KittyImageBudgetState::default()), - kitty_image_budget_changed: Condvar::new(), - #[cfg(debug_assertions)] - terminal_host_reconnect_completion_failures: AtomicU64::new( - std::env::var("CMUX_TUI_TEST_RECONNECT_COMPLETION_FAILURES") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(0), - ), - #[cfg(debug_assertions)] - terminal_host_test_disconnect_after_spawn_ms: AtomicU64::new( - std::env::var("CMUX_TUI_TEST_DISCONNECT_HOST_AFTER_SPAWN_MS") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(0), - ), - #[cfg(test)] - kitty_image_budget_operation: Mutex::new(None), - cell_pixel_lifecycle: Mutex::new(()), - next_cell_pixel_generation: AtomicU64::new(1), - cell_pixels: Mutex::new((8, 16)), - pending_cell_pixels: Mutex::new(None), - cell_pixel_retries: Mutex::new(CellPixelRetryQueue::default()), - #[cfg(test)] - cell_pixel_before_publish: Mutex::new(None), - #[cfg(test)] - cell_pixel_operation: Mutex::new(None), - #[cfg(test)] - cell_pixel_fanout_timeout: Mutex::new(None), - default_colors: Mutex::new(default_colors), - durable_terminal_defaults: AtomicBool::new(has_terminal_defaults), - sidebar_plugin: Mutex::new(SidebarPluginRuntime::default()), - agent_records: Mutex::new(agent_records), - placement_notifications: Mutex::new(HashMap::new()), - terminal_notifications: Mutex::new(terminal_notifications), - notification_ledger: Mutex::new(notification_ledger), - resource_machine_service: OnceLock::new(), - journal_kernel, - journal_ingress, - journal_hook_dispatcher_started: AtomicBool::new(false), - journal_hook_runtime: Arc::new(crate::journal_hooks::JournalHookRuntime::default()), - journal_event_epoch: Mutex::new(0), - journal_event_changed: Condvar::new(), - #[cfg(test)] - journal_segment_prepare_hook: Mutex::new(None), - terminal_exit_waiters: TerminalExitWaiters::default(), - #[cfg(test)] - terminal_exit_state_queries: AtomicU64::new(0), - resource_creation_handoff: Mutex::new(()), - resource_creation_execution: Mutex::new(()), - resource_creation_active: AtomicBool::new(false), - terminal_adoptions: Mutex::new(HashSet::new()), - terminal_exit_detaches: Arc::new(TerminalExitDetachTracker::default()), - terminal_adoption_insert_failures: AtomicU64::new( - std::env::var("CMUX_TUI_TEST_ADOPTION_INSERT_FAILURES") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(0), - ), - server_lifecycle_ready: AtomicBool::new(false), - shutting_down: AtomicBool::new(false), - control_clients: crate::server::ClientRegistry::new(), - surface_operation_admission: Arc::new( - crate::server::ServerSurfaceOperationAdmission::default(), - ), - pairing: PairingBroker::new(), - #[cfg(test)] - test_surface_runtime, - session, - }); - crate::journal_ingress::start(&mux, journal_ingress_receiver)?; - mux.materialize_interrupted_resource_workspaces()?; - mux.materialize_restored_browsers(&contents)?; - #[cfg(unix)] - mux.adopt_terminal_hosts()?; - { - let mut state = mux.state.lock().unwrap(); - state.rebuild_resource_indexes(); - for content in &contents { - if let Some(surface) = state.surfaces.get(&content.slot) { - surface.set_name(content.name.clone()); - } - } - } - let recovery_deadline = Instant::now() + Duration::from_secs(15); - while mux.reconcile_interrupted_resource_creations()? { - if Instant::now() >= recovery_deadline { - mux.shutdown(); - anyhow::bail!("interrupted resource creation did not settle during startup"); - } - std::thread::sleep(Duration::from_millis(25)); - } - crate::journal_hooks::start(&mux)?; - Ok(mux) +#[derive(Default)] +struct TerminalExitWaiters { + next_id: AtomicU64, + waiters: Mutex>>>, +} + +pub(crate) struct TerminalExitSubscription<'a> { + owner: &'a TerminalExitWaiters, + terminal_id: TerminalPublicId, + waiter_id: u64, + wake: Arc, +} + +impl TerminalExitWaiters { + fn subscribe(&self, terminal_id: &TerminalPublicId) -> TerminalExitSubscription<'_> { + let waiter_id = self.next_id.fetch_add(1, Ordering::Relaxed); + let wake = Arc::new(ResourceWaitWake::default()); + self.waiters + .lock() + .unwrap() + .entry(terminal_id.clone()) + .or_default() + .insert(waiter_id, Arc::downgrade(&wake)); + TerminalExitSubscription { owner: self, terminal_id: terminal_id.clone(), waiter_id, wake } } - /// Rehydrate workspace rows that belong to an interrupted correlated - /// creation without exposing them through the public snapshot. Terminal - /// host adoption then restores the live content, and creation settlement - /// publishes the complete resource subtree atomically before startup - /// returns. - fn materialize_interrupted_resource_workspaces(&self) -> anyhow::Result<()> { - let (recoveries, staged) = { - let registry = self.workspace_registry.lock().unwrap(); - ( - registry.interrupted_resource_creation_recoveries()?, - registry.interrupted_resource_workspaces()?, - ) - }; - anyhow::ensure!( - recoveries.len() <= 1, - "multiple interrupted resource creations cannot be recovered atomically" - ); - if staged.is_empty() { - return Ok(()); - } - anyhow::ensure!( - staged.len() == 1, - "multiple interrupted workspace creations cannot be recovered atomically" - ); - let active_workspace = staged - .last() - .map(|(_, workspace)| workspace.id) - .expect("non-empty staged workspace list"); - let mut state = self.state.lock().unwrap(); - for (position, workspace) in staged { - anyhow::ensure!( - position <= state.workspaces.len(), - "interrupted workspace {} has invalid position {position}", - workspace.key - ); - anyhow::ensure!( - state.workspace_by_id(workspace.id).is_none(), - "interrupted workspace {} reuses numeric id {}", - workspace.key, - workspace.id - ); - anyhow::ensure!( - state.workspace_by_key(&workspace.key).is_none(), - "interrupted workspace key {} is already live", - workspace.key - ); - anyhow::ensure!( - !state.resource_indexes.workspaces.contains_key(&workspace.public_id), - "interrupted workspace public id {} is already live", - workspace.public_id - ); - state.workspaces.insert( - position, - Workspace { - id: workspace.id, - public_id: workspace.public_id, - key: workspace.key, - name: workspace.name, - screens: Vec::new(), - active_screen: 0, - }, - ); + fn notify(&self, terminal_id: &TerminalPublicId) { + let waiters = self.waiters.lock().unwrap().remove(terminal_id).unwrap_or_default(); + for waiter in waiters.into_values().filter_map(|waiter| waiter.upgrade()) { + waiter.notify(); } - state.rebuild_workspace_indexes(); - state.rebuild_resource_indexes(); - state.active_workspace = state - .workspace_index(active_workspace) - .context("interrupted active workspace disappeared during restore")?; - Ok(()) } - fn materialize_restored_browsers( - self: &Arc, - contents: &[RestoredResourceContent], - ) -> anyhow::Result<()> { - let opts = self.surface_options.lock().unwrap().clone(); - let cell_pixels = *self.cell_pixels.lock().unwrap(); - for content in contents { - let Some(browser) = content.browser.clone() else { continue }; - let size = (browser.cols, browser.rows); - let url = browser.url; - let surface = browser::new_surface_with_resource_identity( - content.slot, - url.clone(), - size, - cell_pixels, - &opts, - Arc::downgrade(self), - content.identity.clone(), - )?; - surface.set_name(content.name.clone()); - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone())?; - match browser.reconnect { - RegistryBrowserReconnect::Recreate => { - self.start_browser_bootstrap( - surface, - BrowserBootstrap::Provider { tab_id: content.identity.tab_id.clone(), url }, - None, - ); - } - } - } - Ok(()) + #[cfg(test)] + fn waiter_count(&self, terminal_id: &TerminalPublicId) -> usize { + self.waiters.lock().unwrap().get(terminal_id).map(HashMap::len).unwrap_or_default() } +} - #[cfg(unix)] - fn restored_terminal_binding( - &self, - terminal_id: &str, - ) -> anyhow::Result> { - let registry = self.workspace_registry.lock().unwrap(); - let Some(public_id) = registry.terminal_resource_id(terminal_id)? else { - return Ok(None); - }; - drop(registry); - let state = self.state.lock().unwrap(); - let content_id = ContentPublicId::Terminal(public_id.clone()); - let placements = state - .placements_of_content(&content_id) - .iter() - .map(|slot| { - let tab_id = - state.resource_indexes.tab_ids.get(slot).cloned().with_context(|| { - format!("restored terminal placement {slot} has no tab identity") - })?; - Ok((*slot, TabResourceIdentity::new(tab_id, content_id.clone()))) - }) - .collect::>>()?; - Ok(Some(RestoredTerminalBinding { public_id, placements })) +impl TerminalExitSubscription<'_> { + pub(crate) fn wake(&self) -> Arc { + self.wake.clone() } - #[cfg(unix)] - fn adopt_restored_terminal( - self: &Arc, - binding: Option, - options: &SurfaceOptions, - record: &crate::terminal_host_runtime::TerminalHostRecord, - record_path: &Path, - ) -> anyhow::Result> { - let id = binding - .as_ref() - .and_then(|binding| binding.placements.first().map(|(slot, _)| *slot)) - .unwrap_or_else(|| self.next_id()); - match binding { - Some(binding) if !binding.placements.is_empty() => { - let (_, identity) = binding.placements.first().expect("checked placement"); - Surface::adopt_hosted_with_resource_identity( - id, - options.clone(), - Arc::downgrade(self), - record.clone(), - record_path.to_path_buf(), - identity.clone(), - ) - } - Some(binding) => Surface::adopt_hosted_with_terminal_public_id( - id, - options.clone(), - Arc::downgrade(self), - record.clone(), - record_path.to_path_buf(), - binding.public_id, - ), - None => Surface::adopt_hosted( - id, - options.clone(), - Arc::downgrade(self), - record.clone(), - record_path.to_path_buf(), - ), + pub(crate) fn wait_until(&self, deadline: Option) -> bool { + self.wake.wait_until(deadline) + } +} + +impl Drop for TerminalExitSubscription<'_> { + fn drop(&mut self) { + let mut waiters = self.owner.waiters.lock().unwrap(); + let remove_terminal = waiters.get_mut(&self.terminal_id).is_some_and(|terminal_waiters| { + terminal_waiters.remove(&self.waiter_id); + terminal_waiters.is_empty() + }); + if remove_terminal { + waiters.remove(&self.terminal_id); } } +} + +#[cfg(test)] +struct TerminalExitStateQueryGuard<'a>(&'a AtomicU64); + +#[cfg(test)] +impl Drop for TerminalExitStateQueryGuard<'_> { + fn drop(&mut self) { + // Count completed queries. Tests use this release/acquire edge to + // distinguish a waiter blocked after its initial read from one that + // merely entered terminal_exit_state and is still behind the registry + // writer lock. + self.0.fetch_add(1, Ordering::Release); + } +} + +/// The multiplexer. Shared by frontends and the control socket server. +#[derive(Default)] +struct ConfigReloadState { + requested: u64, + applied: u64, +} + +/// Describes why an owner did not confirm a requested configuration reload. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigReloadError { + /// The owner stopped before it could apply the request. + OwnerStopped, + /// The owner did not confirm the request before the failure deadline. + TimedOut, +} + +impl fmt::Display for ConfigReloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::OwnerStopped => "configuration reload owner stopped before applying the request", + Self::TimedOut => "configuration reload owner did not apply the request", + }) + } +} +impl std::error::Error for ConfigReloadError {} + +pub struct Mux { + /// Serializes durable workspace commits, their in-memory projection, and + /// publication of revisioned workspace deltas. Lock order is always + /// registry, then state. + workspace_registry: SignaledMutex, + session_public_id: SessionPublicId, + state: Mutex, + subscribers: MuxEventBroadcaster, + shutdown_requested: AtomicBool, + shutdown_request_watchers: Mutex>>, + surface_creations: SurfaceCreationGate, + async_surface_creations: AsyncSurfaceCreationGate, + shutdown_coordinator: ShutdownCoordinator, + shutdown_owners: ShutdownOwnerLedger, + shutdown_cleanup_lifecycle: ShutdownCleanupLifecycle, + shutdown_owner_reconciler: Arc, + surface_owner_reservations: AtomicUsize, + config_reload: Mutex, + config_reload_changed: Condvar, + next_id: AtomicU64, + next_notification_id: AtomicU64, + next_active_at: AtomicU64, + next_in_process_resize_owner: AtomicU64, + surface_options: Mutex, + provider_workspace: Mutex, + workspace_lifecycles: Mutex>>>, + pending_workspace_surfaces: Mutex>, + client_sizing_lifecycle: Mutex<()>, + client_sizing: Mutex, + #[cfg(test)] + client_resize_before_apply: Mutex>>, + #[cfg(test)] + terminal_move_before_projection: Mutex>>, + #[cfg(test)] + client_rollback_before_wait: Mutex>>, + #[cfg(test)] + workspace_close_before_empty_check: Mutex>>, + #[cfg(test)] + workspace_close_after_selector_resolution: Mutex>>, + #[cfg(test)] + workspace_delta_before_emit: Mutex>, + #[cfg(test)] + resource_rename_after_selector_resolution: Mutex>, + #[cfg(test)] + layout_apply_after_workspace_reservation: Mutex>>, + #[cfg(test)] + terminal_create_after_empty_check: Mutex>>, + #[cfg(test)] + terminal_create_after_materialization_lock: Mutex>>, + #[cfg(test)] + terminal_create_after_workspace_reservation: Mutex>>, + #[cfg(test)] + new_pane_after_spawn: Mutex>, + #[cfg(test)] + browser_tab_after_spawn: Mutex>, + #[cfg(test)] + terminal_adoption_after_attach: Mutex>>, + #[cfg(all(test, unix))] + terminal_adoption_surface_factory: Mutex>, + #[cfg(all(test, unix))] + terminal_host_record_loader: Mutex>, + #[cfg(test)] + shutdown_owner_capacity: AtomicUsize, + #[cfg(test)] + shutdown_attempt_timeout: Mutex>, + #[cfg(test)] + terminal_spawn_after_cell_pixel_snapshot: + Mutex>, + #[cfg(test)] + viewport_split_after_spawn: Mutex>>, + #[cfg(test)] + terminal_create_after_terminal_reservation: Mutex>, + pending_terminal_hosts: Mutex>, + reserved_in_process_terminals: Mutex>, + #[cfg(test)] + resource_mutation_metrics: Mutex>, + #[cfg(test)] + resource_projection_before_commit: Mutex>>, + #[cfg(test)] + resource_close_after_commit: Mutex>>, + #[cfg(test)] + discovered_terminal_termination_requests: Mutex)>>, + #[cfg(test)] + terminal_host_cleanup_hook: Mutex bool + Send + Sync>>>, + #[cfg(test)] + resource_close_cleanup: Mutex>>, + browser_providers: Arc, + browser_runtime: BrowserRuntimeSlot, + active_render_attachments: Arc, + deadline_fanout_pool: DeadlineFanoutPool, + kitty_image_budget: Mutex, + kitty_image_budget_changed: Condvar, + #[cfg(debug_assertions)] + terminal_host_reconnect_completion_failures: AtomicU64, + #[cfg(debug_assertions)] + terminal_host_test_disconnect_after_spawn_ms: AtomicU64, + #[cfg(test)] + kitty_image_budget_operation: Mutex>, + cell_pixel_lifecycle: Mutex<()>, + next_cell_pixel_generation: AtomicU64, + cell_pixels: Mutex<(u16, u16)>, + pending_cell_pixels: Mutex>, + cell_pixel_retries: Mutex, + #[cfg(test)] + cell_pixel_before_publish: Mutex>, + #[cfg(test)] + cell_pixel_operation: Mutex>, + #[cfg(test)] + cell_pixel_fanout_timeout: Mutex>, + default_colors: Mutex, + durable_terminal_defaults: AtomicBool, + sidebar_plugin: Mutex, + agent_records: Mutex>, + /// Nonterminal notifications remain placement-local. Terminal unread + /// state is keyed separately by stable content identity so every view of + /// one terminal shares the same attention marker. + placement_notifications: Mutex>, + terminal_notifications: Mutex>, + notification_ledger: Mutex>, + resource_machine_service: OnceLock>, + journal_kernel: Arc, + journal_ingress: crate::journal_ingress::JournalIngressSender, + journal_hook_dispatcher_started: AtomicBool, + journal_hook_runtime: Arc, + /// Wake-only signal for durable journal subscribers. Consumers always + /// reread SQLite by cursor, so missed or coalesced notifications are safe. + journal_event_epoch: Mutex, + journal_event_changed: Condvar, + #[cfg(test)] + journal_segment_prepare_hook: Mutex>>, + terminal_exit_waiters: TerminalExitWaiters, + #[cfg(test)] + terminal_exit_state_queries: AtomicU64, + /// Keeps a close from removing a just-created surface before legacy + /// callers have resolved the committed public result back to its runtime. + resource_creation_handoff: Mutex<()>, + resource_creation_execution: Mutex<()>, + resource_creation_active: AtomicBool, + terminal_adoptions: Mutex>, #[cfg(unix)] - fn adopt_terminal_hosts(self: &Arc) -> anyhow::Result<()> { - let options = self.surface_options.lock().unwrap().clone(); - let exit_records = match options.terminal_host_root.as_deref() { - Some(root) => crate::terminal_host_runtime::load_terminal_host_exit_records(root)?, - None => Vec::new(), - }; + terminal_adoption_coordinator: Arc, + #[cfg(unix)] + terminal_host_record_scan: TerminalHostRecordScanOwner, + terminal_exit_detaches: Arc, + terminal_adoption_insert_failures: AtomicU64, + server_lifecycle_ready: AtomicBool, + shutting_down: AtomicBool, + daemon_shutdown_requested: AtomicBool, + pub(crate) control_clients: crate::server::ClientRegistry, + pub(crate) surface_operation_admission: Arc, + pairing: PairingBroker, + #[cfg(test)] + test_surface_runtime: bool, + pub session: String, +} + +#[derive(Clone)] +struct RestoredResourceContent { + slot: SurfaceId, + identity: TabResourceIdentity, + name: Option, + browser: Option, +} + +struct RestoredResourceState { + state: State, + next_id: u64, + contents: Vec, +} + +#[cfg(unix)] +struct RestoredTerminalBinding { + public_id: TerminalPublicId, + placements: Vec<(SurfaceId, TabResourceIdentity)>, +} + +impl Mux { + fn default_workspace_name(state: &State) -> String { + state.workspaces.len().to_string() + } + + /// Resolve one public resource path from a single live-state snapshot. + /// Direct content IDs use the reverse resource indexes and never trigger + /// a registry snapshot or process query. + pub fn resolve_resource_path( + &self, + target: crate::ResourceTarget, + selectors: &crate::ResourceSelectors, + ) -> Result { + let registry = self.workspace_registry.lock().unwrap(); + let state = self.state.lock().unwrap(); + resolve_resource_selectors( + &state, + ResourceSelectorContext { + machine_id: registry.machine_id(), + machine_name: None, + session_id: registry.session_id(), + session_name: &self.session, + }, + target, + selectors, + ) + .map(|resolved| resolved.path) + } + + fn resolve_resource_path_in_state( + &self, + state: &State, + registry: &WorkspaceRegistry, + target: crate::ResourceTarget, + selectors: &crate::ResourceSelectors, + ) -> Result { + resolve_resource_selectors( + state, + ResourceSelectorContext { + machine_id: registry.machine_id(), + machine_name: None, + session_id: registry.session_id(), + session_name: &self.session, + }, + target, + selectors, + ) + } + + pub fn new(session: impl Into, surface_options: SurfaceOptions) -> Arc { + Self::new_with_test_surface_runtime( + session, + surface_options, + ProviderWorkspaceState::default(), + false, + ) + } + + /// Builds a mux whose workspace lifecycle is provider-owned from its + /// first control connection. The authority must be provisioned by the + /// provider that owns this mux generation. + pub fn new_provider_managed( + session: impl Into, + surface_options: SurfaceOptions, + authority: ProviderWorkspaceAuthority, + ) -> Arc { + Self::new_with_test_surface_runtime( + session, + surface_options, + ProviderWorkspaceState { + managed: true, + mux_generation: None, + authority_generation: 1, + authority: Some(authority), + }, + false, + ) + } + + /// Builds a provider-owned mux whose authority will be installed through + /// the root-only management socket before lifecycle mutations are allowed. + pub fn new_provider_managed_pending( + session: impl Into, + surface_options: SurfaceOptions, + mux_generation: impl Into, + ) -> anyhow::Result> { + let mux_generation = mux_generation.into(); + validate_mux_generation(&mux_generation)?; + Ok(Self::new_with_test_surface_runtime( + session, + surface_options, + ProviderWorkspaceState { + managed: true, + mux_generation: Some(mux_generation.into_boxed_str()), + authority_generation: 0, + authority: None, + }, + false, + )) + } + + fn new_with_test_surface_runtime( + session: impl Into, + surface_options: SurfaceOptions, + provider_workspace: ProviderWorkspaceState, + #[cfg_attr(not(test), allow(unused_variables))] test_surface_runtime: bool, + ) -> Arc { + let session = session.into(); + let registry = WorkspaceRegistry::in_memory(&session) + .expect("in-memory workspace registry must initialize"); + Self::from_workspace_registry( + session, + surface_options, + registry, + provider_workspace, + test_surface_runtime, + ) + .expect("in-memory workspace registry must load") + } + + pub fn open_persistent( + session: impl Into, + surface_options: SurfaceOptions, + state_root: &Path, + ) -> anyhow::Result> { + let session = session.into(); + let registry = WorkspaceRegistry::open(state_root, &session)?; + Self::from_workspace_registry( + session, + surface_options, + registry, + ProviderWorkspaceState::default(), + false, + ) + } + + pub fn open_persistent_provider_managed( + session: impl Into, + surface_options: SurfaceOptions, + state_root: &Path, + authority: ProviderWorkspaceAuthority, + ) -> anyhow::Result> { + let session = session.into(); + let registry = WorkspaceRegistry::open(state_root, &session)?; + Self::from_workspace_registry( + session, + surface_options, + registry, + ProviderWorkspaceState { + managed: true, + mux_generation: None, + authority_generation: 1, + authority: Some(authority), + }, + false, + ) + } + + pub fn open_persistent_provider_managed_pending( + session: impl Into, + surface_options: SurfaceOptions, + state_root: &Path, + mux_generation: impl Into, + ) -> anyhow::Result> { + let mux_generation = mux_generation.into(); + validate_mux_generation(&mux_generation)?; + let session = session.into(); + let registry = WorkspaceRegistry::open(state_root, &session)?; + Self::from_workspace_registry( + session, + surface_options, + registry, + ProviderWorkspaceState { + managed: true, + mux_generation: Some(mux_generation.into_boxed_str()), + authority_generation: 0, + authority: None, + }, + false, + ) + } + + fn from_workspace_registry( + session: String, + mut surface_options: SurfaceOptions, + registry: WorkspaceRegistry, + provider_workspace: ProviderWorkspaceState, + #[cfg_attr(not(test), allow(unused_variables))] test_surface_runtime: bool, + ) -> anyhow::Result> { + let snapshot = registry.snapshot()?; + let topology = registry.resource_topology_snapshot()?; + let RestoredResourceState { mut state, next_id, contents } = + restore_resource_state(snapshot, topology)?; + let RestoredPublicProjections { + default_colors, + has_terminal_defaults, + next_notification_id, + agent_records, + terminal_notifications, + notification_ledger, + } = restore_public_projections(&state, registry.public_projections()?)?; + let journal_producers = registry.journal_producer_manifests()?; + let session_public_id = registry.session_id().clone(); + let journal_kernel = crate::journal_kernel::JournalKernel::new( + registry.session_journal_database_path(), + &journal_producers, + )?; + let (journal_ingress, journal_ingress_receiver) = + crate::journal_ingress::JournalIngressSender::new( + registry.session_journal_database_path().is_some(), + ); + surface_options.browser_session_name = session.clone(); + #[cfg(unix)] + crate::process_session::initialize_stable_process_signaling(); + Self::rebuild_split_screen_index(&mut state); + let mux = Arc::new(Mux { + workspace_registry: SignaledMutex::new(registry), + session_public_id, + state: Mutex::new(state), + subscribers: MuxEventBroadcaster::default(), + shutdown_requested: AtomicBool::new(false), + shutdown_request_watchers: Mutex::new(Vec::new()), + surface_creations: SurfaceCreationGate::default(), + async_surface_creations: AsyncSurfaceCreationGate::default(), + shutdown_coordinator: ShutdownCoordinator::default(), + shutdown_owners: ShutdownOwnerLedger::default(), + shutdown_cleanup_lifecycle: ShutdownCleanupLifecycle::default(), + shutdown_owner_reconciler: Arc::new(ShutdownOwnerReconciler::default()), + surface_owner_reservations: AtomicUsize::new(0), + config_reload: Mutex::new(ConfigReloadState::default()), + config_reload_changed: Condvar::new(), + next_id: AtomicU64::new(next_id), + next_notification_id: AtomicU64::new(next_notification_id), + next_active_at: AtomicU64::new(1), + next_in_process_resize_owner: AtomicU64::new(1), + surface_options: Mutex::new(surface_options), + provider_workspace: Mutex::new(provider_workspace), + workspace_lifecycles: Mutex::new(HashMap::new()), + pending_workspace_surfaces: Mutex::new(HashMap::new()), + client_sizing_lifecycle: Mutex::new(()), + client_sizing: Mutex::new(ClientSizingState::default()), + #[cfg(test)] + client_resize_before_apply: Mutex::new(None), + #[cfg(test)] + terminal_move_before_projection: Mutex::new(None), + #[cfg(test)] + client_rollback_before_wait: Mutex::new(None), + #[cfg(test)] + workspace_close_before_empty_check: Mutex::new(None), + #[cfg(test)] + workspace_close_after_selector_resolution: Mutex::new(None), + #[cfg(test)] + workspace_delta_before_emit: Mutex::new(None), + #[cfg(test)] + resource_rename_after_selector_resolution: Mutex::new(None), + #[cfg(test)] + layout_apply_after_workspace_reservation: Mutex::new(None), + #[cfg(test)] + terminal_create_after_empty_check: Mutex::new(None), + #[cfg(test)] + terminal_create_after_materialization_lock: Mutex::new(None), + #[cfg(test)] + terminal_create_after_workspace_reservation: Mutex::new(None), + #[cfg(test)] + new_pane_after_spawn: Mutex::new(None), + #[cfg(test)] + browser_tab_after_spawn: Mutex::new(None), + #[cfg(test)] + terminal_adoption_after_attach: Mutex::new(None), + #[cfg(all(test, unix))] + terminal_adoption_surface_factory: Mutex::new(None), + #[cfg(all(test, unix))] + terminal_host_record_loader: Mutex::new(None), + #[cfg(test)] + shutdown_owner_capacity: AtomicUsize::new(usize::MAX), + #[cfg(test)] + shutdown_attempt_timeout: Mutex::new(None), + #[cfg(test)] + terminal_spawn_after_cell_pixel_snapshot: Mutex::new(None), + #[cfg(test)] + viewport_split_after_spawn: Mutex::new(None), + #[cfg(test)] + terminal_create_after_terminal_reservation: Mutex::new(None), + pending_terminal_hosts: Mutex::new(HashMap::new()), + reserved_in_process_terminals: Mutex::new(HashMap::new()), + #[cfg(test)] + resource_mutation_metrics: Mutex::new(None), + #[cfg(test)] + resource_projection_before_commit: Mutex::new(None), + #[cfg(test)] + resource_close_after_commit: Mutex::new(None), + #[cfg(test)] + discovered_terminal_termination_requests: Mutex::new(Vec::new()), + #[cfg(test)] + terminal_host_cleanup_hook: Mutex::new(None), + #[cfg(test)] + resource_close_cleanup: Mutex::new(None), + browser_providers: Arc::new(BrowserProviderRegistry::default()), + browser_runtime: BrowserRuntimeSlot::default(), + active_render_attachments: Arc::new(AtomicUsize::new(0)), + deadline_fanout_pool: DeadlineFanoutPool::new(), + kitty_image_budget: Mutex::new(KittyImageBudgetState::default()), + kitty_image_budget_changed: Condvar::new(), + #[cfg(debug_assertions)] + terminal_host_reconnect_completion_failures: AtomicU64::new( + std::env::var("CMUX_TUI_TEST_RECONNECT_COMPLETION_FAILURES") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(0), + ), + #[cfg(debug_assertions)] + terminal_host_test_disconnect_after_spawn_ms: AtomicU64::new( + std::env::var("CMUX_TUI_TEST_DISCONNECT_HOST_AFTER_SPAWN_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(0), + ), + #[cfg(test)] + kitty_image_budget_operation: Mutex::new(None), + cell_pixel_lifecycle: Mutex::new(()), + next_cell_pixel_generation: AtomicU64::new(1), + cell_pixels: Mutex::new((8, 16)), + pending_cell_pixels: Mutex::new(None), + cell_pixel_retries: Mutex::new(CellPixelRetryQueue::default()), + #[cfg(test)] + cell_pixel_before_publish: Mutex::new(None), + #[cfg(test)] + cell_pixel_operation: Mutex::new(None), + #[cfg(test)] + cell_pixel_fanout_timeout: Mutex::new(None), + default_colors: Mutex::new(default_colors), + durable_terminal_defaults: AtomicBool::new(has_terminal_defaults), + sidebar_plugin: Mutex::new(SidebarPluginRuntime::default()), + agent_records: Mutex::new(agent_records), + placement_notifications: Mutex::new(HashMap::new()), + terminal_notifications: Mutex::new(terminal_notifications), + notification_ledger: Mutex::new(notification_ledger), + resource_machine_service: OnceLock::new(), + journal_kernel, + journal_ingress, + journal_hook_dispatcher_started: AtomicBool::new(false), + journal_hook_runtime: Arc::new(crate::journal_hooks::JournalHookRuntime::default()), + journal_event_epoch: Mutex::new(0), + journal_event_changed: Condvar::new(), + #[cfg(test)] + journal_segment_prepare_hook: Mutex::new(None), + terminal_exit_waiters: TerminalExitWaiters::default(), + #[cfg(test)] + terminal_exit_state_queries: AtomicU64::new(0), + resource_creation_handoff: Mutex::new(()), + resource_creation_execution: Mutex::new(()), + resource_creation_active: AtomicBool::new(false), + terminal_adoptions: Mutex::new(HashSet::new()), + #[cfg(unix)] + terminal_adoption_coordinator: Arc::new(TerminalAdoptionCoordinator::default()), + #[cfg(unix)] + terminal_host_record_scan: TerminalHostRecordScanOwner::default(), + terminal_exit_detaches: Arc::new(TerminalExitDetachTracker::default()), + terminal_adoption_insert_failures: AtomicU64::new( + std::env::var("CMUX_TUI_TEST_ADOPTION_INSERT_FAILURES") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(0), + ), + server_lifecycle_ready: AtomicBool::new(false), + shutting_down: AtomicBool::new(false), + daemon_shutdown_requested: AtomicBool::new(false), + control_clients: crate::server::ClientRegistry::new(), + surface_operation_admission: Arc::new( + crate::server::ServerSurfaceOperationAdmission::default(), + ), + pairing: PairingBroker::new(), + #[cfg(test)] + test_surface_runtime, + session, + }); + mux.shutdown_owner_reconciler.bind(Arc::downgrade(&mux)); + crate::journal_ingress::start(&mux, journal_ingress_receiver)?; + mux.materialize_interrupted_resource_workspaces()?; + mux.materialize_restored_browsers(&contents)?; + #[cfg(unix)] + { + if mux.surface_options.lock().unwrap().terminal_host_root.is_some() { + mux.ensure_terminal_adoption_workers()?; + } + mux.adopt_terminal_hosts()?; + } + { + let mut state = mux.state.lock().unwrap(); + state.rebuild_resource_indexes(); + for content in &contents { + if let Some(surface) = state.surfaces.get(&content.slot) { + surface.set_name(content.name.clone()); + } + } + } + let recovery_deadline = Instant::now() + Duration::from_secs(15); + while mux.reconcile_interrupted_resource_creations()? { + if Instant::now() >= recovery_deadline { + let _ = mux.shutdown(); + anyhow::bail!("interrupted resource creation did not settle during startup"); + } + std::thread::sleep(Duration::from_millis(25)); + } + crate::journal_hooks::start(&mux)?; + Ok(mux) + } + + /// Rehydrate workspace rows that belong to an interrupted correlated + /// creation without exposing them through the public snapshot. Terminal + /// host adoption then restores the live content, and creation settlement + /// publishes the complete resource subtree atomically before startup + /// returns. + fn materialize_interrupted_resource_workspaces(&self) -> anyhow::Result<()> { + let (recoveries, staged) = { + let registry = self.workspace_registry.lock().unwrap(); + ( + registry.interrupted_resource_creation_recoveries()?, + registry.interrupted_resource_workspaces()?, + ) + }; + anyhow::ensure!( + recoveries.len() <= 1, + "multiple interrupted resource creations cannot be recovered atomically" + ); + if staged.is_empty() { + return Ok(()); + } + anyhow::ensure!( + staged.len() == 1, + "multiple interrupted workspace creations cannot be recovered atomically" + ); + let active_workspace = staged + .last() + .map(|(_, workspace)| workspace.id) + .expect("non-empty staged workspace list"); + let mut state = self.state.lock().unwrap(); + for (position, workspace) in staged { + anyhow::ensure!( + position <= state.workspaces.len(), + "interrupted workspace {} has invalid position {position}", + workspace.key + ); + anyhow::ensure!( + state.workspace_by_id(workspace.id).is_none(), + "interrupted workspace {} reuses numeric id {}", + workspace.key, + workspace.id + ); + anyhow::ensure!( + state.workspace_by_key(&workspace.key).is_none(), + "interrupted workspace key {} is already live", + workspace.key + ); + anyhow::ensure!( + !state.resource_indexes.workspaces.contains_key(&workspace.public_id), + "interrupted workspace public id {} is already live", + workspace.public_id + ); + state.workspaces.insert( + position, + Workspace { + id: workspace.id, + public_id: workspace.public_id, + key: workspace.key, + name: workspace.name, + screens: Vec::new(), + active_screen: 0, + }, + ); + } + state.rebuild_workspace_indexes(); + state.rebuild_resource_indexes(); + state.active_workspace = state + .workspace_index(active_workspace) + .context("interrupted active workspace disappeared during restore")?; + Ok(()) + } + + fn materialize_restored_browsers( + self: &Arc, + contents: &[RestoredResourceContent], + ) -> anyhow::Result<()> { + let opts = self.surface_options.lock().unwrap().clone(); + let cell_pixels = *self.cell_pixels.lock().unwrap(); + for content in contents { + let Some(browser) = content.browser.clone() else { continue }; + let mut owner_reservation = self.reserve_surface_owner()?; + let size = (browser.cols, browser.rows); + let url = browser.url; + let surface = browser::new_surface_with_resource_identity( + content.slot, + url.clone(), + size, + cell_pixels, + &opts, + Arc::downgrade(self), + content.identity.clone(), + )?; + surface.set_name(content.name.clone()); + let insertion = { + let mut state = self.state.lock().unwrap(); + insert_reserved_surface_checked( + self, + &mut state, + surface.clone(), + &mut owner_reservation, + ) + }; + if let Err(error) = insertion { + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); + return Err(error); + } + match browser.reconnect { + RegistryBrowserReconnect::Recreate => { + self.start_browser_bootstrap( + surface, + BrowserSurfaceBootstrap::Provider { + tab_id: content.identity.tab_id.clone(), + url, + }, + ); + } + } + } + Ok(()) + } + + #[cfg(unix)] + fn restored_terminal_binding( + &self, + terminal_id: &str, + ) -> anyhow::Result> { + let registry = self.workspace_registry.lock().unwrap(); + let Some(public_id) = registry.terminal_resource_id(terminal_id)? else { + return Ok(None); + }; + drop(registry); + let state = self.state.lock().unwrap(); + let content_id = ContentPublicId::Terminal(public_id.clone()); + let placements = state + .placements_of_content(&content_id) + .iter() + .map(|slot| { + let tab_id = + state.resource_indexes.tab_ids.get(slot).cloned().with_context(|| { + format!("restored terminal placement {slot} has no tab identity") + })?; + Ok((*slot, TabResourceIdentity::new(tab_id, content_id.clone()))) + }) + .collect::>>()?; + Ok(Some(RestoredTerminalBinding { public_id, placements })) + } + + #[cfg(unix)] + fn adopt_restored_terminal( + self: &Arc, + binding: Option, + options: &SurfaceOptions, + record: &crate::terminal_host_runtime::TerminalHostRecord, + record_path: &Path, + ) -> anyhow::Result> { + let id = binding + .as_ref() + .and_then(|binding| binding.placements.first().map(|(slot, _)| *slot)) + .unwrap_or_else(|| self.next_id()); + match binding { + Some(binding) if !binding.placements.is_empty() => { + let (_, identity) = binding.placements.first().expect("checked placement"); + Surface::adopt_hosted_with_resource_identity( + id, + options.clone(), + Arc::downgrade(self), + record.clone(), + record_path.to_path_buf(), + identity.clone(), + ) + } + Some(binding) => Surface::adopt_hosted_with_terminal_public_id( + id, + options.clone(), + Arc::downgrade(self), + record.clone(), + record_path.to_path_buf(), + binding.public_id, + ), + None => Surface::adopt_hosted( + id, + options.clone(), + Arc::downgrade(self), + record.clone(), + record_path.to_path_buf(), + ), + } + } + + #[cfg(unix)] + fn adopt_terminal_hosts(self: &Arc) -> anyhow::Result<()> { + let options = self.surface_options.lock().unwrap().clone(); + let exit_records = match options.terminal_host_root.as_deref() { + Some(root) => crate::terminal_host_runtime::load_terminal_host_exit_records(root)?, + None => Vec::new(), + }; let records = match options.terminal_host_root.as_deref() { Some(root) => crate::terminal_host_runtime::load_terminal_host_records(root)?, None => Vec::new(), @@ -2737,68 +3875,11 @@ impl Mux { handled_terminals.insert(terminal_id.clone()); continue; } - let restored_binding = self.restored_terminal_binding(&terminal_id)?; - // Bound startup head-of-line blocking per host. One handshake is - // enough for healthy hosts; live or indeterminate failures - // continue on the asynchronous adoption loop instead of retrying - // serially ahead of every later terminal. - let adopted = self - .adopt_restored_terminal(restored_binding, &options, &record, &record_path) - .ok(); - let surface = match adopted { - Some(surface) => surface, - None => { - if terminal_host_record_liveness(&record_path, &record) - == TerminalHostLiveness::Dead - { - let _ = crate::terminal_host_runtime::remove_stale_terminal_host_record( - &record_path, - &record, - ); - self.mark_terminal_exited_and_detach( - &terminal_id, - "terminal-host-proven-dead", - "host-process-ended-before-adoption", - &options, - )?; - handled_terminals.insert(terminal_id.clone()); - } else { - // Socket loss and descriptor pressure are not process - // death proof. Keep the capability and retry the same - // host rather than spawning a replacement shell. - handled_terminals.insert(terminal_id.clone()); - self.schedule_terminal_adoption(options.clone(), record, record_path); - } - continue; - } - }; - if self - .finish_terminal_adoption(&terminal_id, &record.incarnation, surface.clone()) - .is_err() - { - let host_is_dead = surface.is_dead() - || terminal_host_record_liveness(&record_path, &record) - == TerminalHostLiveness::Dead; - surface.disconnect_for_daemon_shutdown(); - handled_terminals.insert(terminal_id.clone()); - if host_is_dead { - self.mark_terminal_exited_and_detach( - &terminal_id, - "terminal-adoption-failed", - "host-exited-during-adoption", - &options, - )?; - let _ = crate::terminal_host_runtime::remove_stale_terminal_host_record( - &record_path, - &record, - ); - } else { - self.schedule_terminal_adoption(options.clone(), record, record_path); - } - continue; - } + // Host handshakes can consume their full timeout. Queue every + // live or indeterminate record before the control socket is + // published so startup time is independent of record count. handled_terminals.insert(terminal_id); - self.reap_if_dead(&surface); + self.schedule_terminal_adoption(options.clone(), record, record_path); } // No launcher survives a daemon restart. A durable lifecycle row @@ -2815,6 +3896,33 @@ impl Mux { self.detach_exited_terminal_topology(&terminal.terminal_id)?; continue; } + if let Some(root) = options.terminal_host_root.as_deref() { + // The directory scan deliberately tolerates malformed debris. + // Before converting a durable live row into record absence, + // exclude its launcher through the same transferable guard + // held until the child publishes. The exact canonical probe + // then either observes that publication, rejects unreadable + // ownership, or proves no child can publish after absence. + let _publication = crate::terminal_host_runtime::acquire_terminal_host_publication( + root, + &terminal.terminal_id, + )?; + let exact_record = crate::terminal_host_runtime::load_terminal_host_record( + root, + &terminal.terminal_id, + ) + .with_context(|| { + format!( + "load exact terminal-host record for durable terminal {}", + terminal.terminal_id + ) + })?; + anyhow::ensure!( + exact_record.is_none(), + "terminal-host record appeared after adoption discovery for {}", + terminal.terminal_id + ); + } self.mark_terminal_exited_and_detach( &terminal.terminal_id, "terminal-record-missing", @@ -2880,7 +3988,9 @@ impl Mux { terminal_id: &str, incarnation: &str, surface: Arc, + owner_reservation: &mut SurfaceOwnerReservation<'_>, ) -> anyhow::Result<()> { + let _creation = self.begin_surface_creation()?; let _pending_host_release = PendingTerminalHostRelease(surface.clone()); if surface.is_dead() { self.persist_terminal_exit( @@ -2920,7 +4030,7 @@ impl Mux { !self.consume_terminal_adoption_insert_failure(), "injected terminal adoption topology failure" ); - insert_restored_terminal_runtime_checked(&mut state, surface)?; + insert_restored_terminal_runtime_checked(self, &mut state, surface)?; } else { // One-release import path for a host that predates public content // identities. Give it a real initial placement so the normal @@ -2937,7 +4047,7 @@ impl Mux { !self.consume_terminal_adoption_insert_failure(), "injected terminal adoption topology failure" ); - insert_surface_checked(&mut state, surface)?; + insert_surface_with_active_reservation_checked(self, &mut state, surface)?; { let workspace = &mut state.workspaces[workspace_index]; workspace.screens.push(Screen { @@ -2956,249 +4066,474 @@ impl Mux { }); workspace.active_screen = workspace.screens.len() - 1; } - // Adoption materializes a live pane without stealing focus, but it - // must still advance the pane-set revision used by frontend focus - // history pruning. - state.insert_pane(pane); - state.rebuild_resource_indexes(); + // Adoption materializes a live pane without stealing focus, but it + // must still advance the pane-set revision used by frontend focus + // history pruning. + state.insert_pane(pane); + state.rebuild_resource_indexes(); + } + + let revision = match commit_terminal_lifecycle( + &mut registry, + "terminal-ready", + "terminal-adopted", + terminal_id, + TerminalLifecycle::Running, + Some(incarnation), + None, + ) { + Ok((_, revision)) => revision, + Err(error) => { + *state = before; + return Err(error); + } + }; + owner_reservation.release(); + drop(state); + self.emit_terminal_registry_changed(®istry, revision); + #[cfg(debug_assertions)] + if let Some(signal) = std::env::var_os("CMUX_TUI_TEST_TERMINAL_ADOPTED_SIGNAL") { + let _ = std::fs::write(signal, b"1"); + } + Ok(()) + } + + #[cfg(unix)] + fn ensure_terminal_adoption_workers(self: &Arc) -> std::io::Result<()> { + let coordinator = self.terminal_adoption_coordinator.clone(); + let (existing, missing) = { + let mut state = coordinator.state.lock().unwrap(); + if state.stopping { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "terminal adoption is stopping", + )); + } + if state.workers_running >= TERMINAL_ADOPTION_WORKERS { + return Ok(()); + } + let existing = state.workers_running; + let missing = TERMINAL_ADOPTION_WORKERS - existing; + state.workers_running += missing; + (existing, missing) + }; + let mut started = 0; + let mut first_error = None; + for worker in 0..missing { + let worker = existing + worker; + let mux = Arc::downgrade(self); + let worker_coordinator = coordinator.clone(); + match std::thread::Builder::new().name(format!("terminal-adoption-{worker}")).spawn( + move || { + Self::run_terminal_adoption_worker(mux, worker_coordinator); + }, + ) { + Ok(_) => started += 1, + Err(error) => { + let mut state = coordinator.state.lock().unwrap(); + state.workers_running = state.workers_running.saturating_sub(1); + coordinator.wake.notify_all(); + first_error.get_or_insert(error); + } + } + } + if existing + started > 0 { + Ok(()) + } else { + Err(first_error.expect("at least one worker spawn failed")) + } + } + + #[cfg(unix)] + fn consume_terminal_adoption_insert_failure(&self) -> bool { + self.terminal_adoption_insert_failures + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| remaining.checked_sub(1)) + .is_ok() + } + + #[cfg(unix)] + fn schedule_terminal_adoption( + self: &Arc, + options: SurfaceOptions, + record: crate::terminal_host_runtime::TerminalHostRecord, + record_path: std::path::PathBuf, + ) { + if self.shutting_down.load(Ordering::Acquire) { + return; + } + let terminal_id = record.terminal_id.clone(); + { + let mut tracked = self.terminal_adoptions.lock().unwrap(); + if tracked.contains(&terminal_id) { + return; + } + tracked.insert(terminal_id.clone()); + } + + let task = TerminalAdoptionTask { + options, + record, + record_path, + next_attempt: Instant::now() + Duration::from_millis(100), + delay: Duration::from_millis(100), + }; + let mut state = self.terminal_adoption_coordinator.state.lock().unwrap(); + if !state.enqueue(task) { + self.terminal_adoptions.lock().unwrap().remove(&terminal_id); + state.rescan_required = true; + state.next_rescan.get_or_insert_with(Instant::now); + self.terminal_adoption_coordinator.wake.notify_one(); + return; + } + drop(state); + self.terminal_adoption_coordinator.wake.notify_one(); + if self.ensure_terminal_adoption_workers().is_err() { + self.terminal_adoptions.lock().unwrap().remove(&terminal_id); + let mut state = self.terminal_adoption_coordinator.state.lock().unwrap(); + state.tasks.retain(|task| task.record.terminal_id != terminal_id); + state.deferred.retain(|task| task.record.terminal_id != terminal_id); + state.promote_deferred(); + state.rescan_required = true; + state.next_rescan.get_or_insert_with(Instant::now); + } + } + + #[cfg(unix)] + fn rescan_terminal_adoptions(self: &Arc) -> bool { + let options = self.surface_options.lock().unwrap().clone(); + let Some(root) = options.terminal_host_root.as_deref() else { return true }; + let Ok(records) = crate::terminal_host_runtime::load_terminal_host_records(root) else { + return false; + }; + let registered = { + let registry = self.workspace_registry.lock().unwrap(); + let Ok(terminals) = registry.terminal_ids_including_tombstones() else { + return false; + }; + terminals.into_iter().collect::>() + }; + let live_incarnations = { + let state = self.state.lock().unwrap(); + let mut live = HashMap::>::new(); + for identity in unique_surface_runtimes(&state) + .iter() + .filter_map(|surface| surface.live_terminal_host_identity()) + { + live.entry(identity.terminal_id).or_default().insert(identity.incarnation); + } + live + }; + for (record_path, record) in records { + let already_live = registered.contains(&record.terminal_id) + && live_incarnations + .get(&record.terminal_id) + .is_some_and(|incarnations| incarnations.contains(&record.incarnation)); + if already_live { + continue; + } + self.schedule_terminal_adoption(options.clone(), record, record_path); + } + true + } + + #[cfg(unix)] + fn run_terminal_adoption_worker( + mux: Weak, + coordinator: Arc, + ) { + loop { + let stopping = coordinator.state.lock().unwrap().stopping; + if stopping { + Self::terminal_adoption_worker_stopped(&coordinator); + return; + } + let Some(mux) = mux.upgrade() else { + Self::terminal_adoption_worker_stopped(&coordinator); + return; + }; + if mux.shutting_down.load(Ordering::Acquire) { + mux.terminal_adoptions.lock().unwrap().clear(); + drop(mux); + Self::terminal_adoption_worker_stopped(&coordinator); + return; + } + + let mut state = coordinator.state.lock().unwrap(); + let now = Instant::now(); + if state.rescan_due(now) { + state.rescan_required = false; + state.next_rescan = None; + drop(state); + if !mux.rescan_terminal_adoptions() { + let mut state = coordinator.state.lock().unwrap(); + state.rescan_required = true; + state.next_rescan = Some(Instant::now() + state.rescan_delay); + state.rescan_delay = (state.rescan_delay * 2).min(Duration::from_secs(5)); + } else { + coordinator.state.lock().unwrap().rescan_delay = Duration::from_millis(100); + } + continue; + } + + if let Some(index) = state.tasks.iter().position(|task| task.next_attempt <= now) { + let mut task = state.tasks.swap_remove(index); + state.in_flight += 1; + drop(state); + let complete = mux.try_terminal_adoption(&task); + let mut state = coordinator.state.lock().unwrap(); + state.in_flight = + state.in_flight.checked_sub(1).expect("adoption worker tracks in-flight tasks"); + if complete || mux.shutting_down.load(Ordering::Acquire) { + mux.terminal_adoptions.lock().unwrap().remove(&task.record.terminal_id); + } else { + #[cfg(debug_assertions)] + if let Some(signal) = + std::env::var_os("CMUX_TUI_TEST_TERMINAL_ADOPTION_RETRY_SIGNAL") + { + let _ = std::fs::write(signal, b"1"); + } + task.delay = (task.delay * 2).min(Duration::from_secs(5)); + task.next_attempt = Instant::now() + task.delay; + state.requeue_retry(task); + } + state.promote_deferred(); + drop(state); + coordinator.wake.notify_one(); + continue; + } + + let wait = state + .tasks + .iter() + .map(|task| task.next_attempt.saturating_duration_since(now)) + .min() + .unwrap_or(Duration::from_secs(1)) + .min(Duration::from_secs(1)); + drop(mux); + let _ = coordinator.wake.wait_timeout(state, wait).unwrap(); } + } - let revision = match commit_terminal_lifecycle( - &mut registry, - "terminal-ready", - "terminal-adopted", - terminal_id, - TerminalLifecycle::Running, - Some(incarnation), - None, - ) { - Ok((_, revision)) => revision, - Err(error) => { - *state = before; - return Err(error); - } - }; - drop(state); - self.emit_terminal_registry_changed(®istry, revision); - Ok(()) + #[cfg(unix)] + fn terminal_adoption_worker_stopped(coordinator: &TerminalAdoptionCoordinator) { + let mut state = coordinator.state.lock().unwrap(); + state.workers_running = state.workers_running.saturating_sub(1); + coordinator.wake.notify_all(); } #[cfg(unix)] - fn consume_terminal_adoption_insert_failure(&self) -> bool { - self.terminal_adoption_insert_failures - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| remaining.checked_sub(1)) - .is_ok() + fn request_terminal_adoption_stop(&self) { + self.terminal_adoptions.lock().unwrap().clear(); + let mut state = self.terminal_adoption_coordinator.state.lock().unwrap(); + state.stopping = true; + state.tasks.clear(); + state.deferred.clear(); + self.terminal_adoption_coordinator.wake.notify_all(); } #[cfg(unix)] - fn schedule_terminal_adoption( - self: &Arc, - options: SurfaceOptions, - record: crate::terminal_host_runtime::TerminalHostRecord, - record_path: std::path::PathBuf, - ) { - let terminal_id = record.terminal_id.clone(); - if !self.terminal_adoptions.lock().unwrap().insert(terminal_id.clone()) { - return; + fn wait_for_terminal_adoption_workers_until(&self, deadline: Instant) -> bool { + let mut state = self.terminal_adoption_coordinator.state.lock().unwrap(); + while state.workers_running != 0 { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = + self.terminal_adoption_coordinator.wake.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && state.workers_running != 0 { + return false; + } } - let cleanup_id = terminal_id.clone(); - let mux = self.clone(); - let spawn_result = std::thread::Builder::new() - .name(format!("terminal-adopt-{terminal_id}")) - .spawn(move || { - let mut delay = Duration::from_millis(100); - loop { - if mux.shutting_down.load(Ordering::Acquire) { - break; - } - std::thread::sleep(delay); - if mux.shutting_down.load(Ordering::Acquire) { - break; - } - let terminal = mux - .workspace_registry - .lock() - .unwrap() - .terminal_record(&terminal_id) - .ok() - .flatten(); - let Some(terminal) = terminal else { - if cleanup_terminal_host_record(&record, &record_path) { - break; - } - delay = (delay * 2).min(Duration::from_secs(5)); - continue; + true + } + + #[cfg(unix)] + fn try_terminal_adoption(self: &Arc, task: &TerminalAdoptionTask) -> bool { + let terminal_id = &task.record.terminal_id; + let terminal = match self.workspace_registry.lock().unwrap().terminal_record(terminal_id) { + Ok(terminal) => terminal, + Err(_) => return false, + }; + let Some(terminal) = terminal else { + return cleanup_terminal_host_record(&task.record, &task.record_path); + }; + if terminal.lifecycle == TerminalLifecycle::Exited { + if self.detach_exited_terminal_topology(terminal_id).is_err() { + return false; + } + return cleanup_terminal_host_record(&task.record, &task.record_path); + } + if terminal.lifecycle == TerminalLifecycle::Tombstoned { + return cleanup_terminal_host_record(&task.record, &task.record_path); + } + if terminal + .incarnation + .as_deref() + .is_some_and(|incarnation| incarnation != task.record.incarnation) + { + if self + .mark_terminal_exited_and_detach( + terminal_id, + "terminal-incarnation-mismatch", + "host-incarnation-mismatch", + &task.options, + ) + .is_err() + { + return false; + } + return cleanup_terminal_host_record(&task.record, &task.record_path); + } + if terminal.lifecycle == TerminalLifecycle::Running { + let already_live = match self.resolve_terminal(terminal_id) { + Ok(resolution) => resolution.is_some_and(|resolution| resolution.surface.is_some()), + Err(_) => return false, + }; + if already_live { + return true; + } + if self + .transition_terminal_lifecycle( + "terminal-adopting", + "retry-terminal-adoption", + terminal_id, + TerminalLifecycle::Adopting, + Some(&task.record.incarnation), + None, + ) + .is_err() + { + let current = + match self.workspace_registry.lock().unwrap().terminal_record(terminal_id) { + Ok(Some(current)) => current, + Ok(None) | Err(_) => return false, }; - if matches!( - terminal.lifecycle, - TerminalLifecycle::Exited | TerminalLifecycle::Tombstoned - ) { - if terminal.lifecycle == TerminalLifecycle::Exited - && let Err(error) = mux.detach_exited_terminal_topology(&terminal_id) - { - eprintln!( - "cmux-tui: could not detach exited terminal \ - {terminal_id}: {error:#}" - ); - delay = (delay * 2).min(Duration::from_secs(5)); - continue; - } - if cleanup_terminal_host_record(&record, &record_path) { - break; - } - delay = (delay * 2).min(Duration::from_secs(5)); - continue; - } - if terminal - .incarnation - .as_deref() - .is_some_and(|incarnation| incarnation != record.incarnation) - { - if let Err(error) = mux.mark_terminal_exited_and_detach( - &terminal_id, - "terminal-incarnation-mismatch", - "host-incarnation-mismatch", - &options, - ) { - eprintln!( - "cmux-tui: could not reconcile exited terminal \ - {terminal_id}: {error:#}" - ); - delay = (delay * 2).min(Duration::from_secs(5)); - continue; - } - if cleanup_terminal_host_record(&record, &record_path) { - break; - } - delay = (delay * 2).min(Duration::from_secs(5)); - continue; - } - if terminal.lifecycle == TerminalLifecycle::Running { - let already_live = mux - .resolve_terminal(&terminal_id) - .ok() - .flatten() - .is_some_and(|resolution| resolution.surface.is_some()); - if already_live { - break; - } - if mux - .transition_terminal_lifecycle( - "terminal-adopting", - "retry-terminal-adoption", - &terminal_id, - TerminalLifecycle::Adopting, - Some(&record.incarnation), - None, - ) - .is_err() - { - break; - } - } - if terminal_host_record_liveness(&record_path, &record) - == TerminalHostLiveness::Dead - { - if let Err(error) = mux.mark_terminal_exited_and_detach( - &terminal_id, - "terminal-host-proven-dead", - "host-process-ended-before-adoption", - &options, - ) { - eprintln!( - "cmux-tui: could not reconcile exited terminal \ - {terminal_id}: {error:#}" - ); - delay = (delay * 2).min(Duration::from_secs(5)); - continue; - } - let _ = crate::terminal_host_runtime::remove_stale_terminal_host_record( - &record_path, - &record, - ); - break; + if current.lifecycle == TerminalLifecycle::Exited { + if self.detach_exited_terminal_topology(terminal_id).is_err() { + return false; } - let restored_binding = match mux.restored_terminal_binding(&terminal_id) { - Ok(binding) => binding, - Err(_) => { - delay = (delay * 2).min(Duration::from_secs(5)); - continue; - } - }; - let adopted = mux.adopt_restored_terminal( - restored_binding, - &options, - &record, - &record_path, - ); - if let Ok(surface) = adopted { - if mux - .finish_terminal_adoption( - &terminal_id, - &record.incarnation, - surface.clone(), - ) - .is_ok() - { - mux.reap_if_dead(&surface); - break; - } - let host_is_dead = surface.is_dead() - || terminal_host_record_liveness(&record_path, &record) - == TerminalHostLiveness::Dead; - surface.disconnect_for_daemon_shutdown(); - if host_is_dead { - if let Err(error) = mux.mark_terminal_exited_and_detach( - &terminal_id, - "terminal-adoption-failed", - "host-exited-during-adoption", - &options, - ) { - eprintln!( - "cmux-tui: could not reconcile exited terminal \ - {terminal_id}: {error:#}" - ); - delay = (delay * 2).min(Duration::from_secs(5)); - continue; + return cleanup_terminal_host_record(&task.record, &task.record_path); + } + if current.lifecycle == TerminalLifecycle::Tombstoned { + return cleanup_terminal_host_record(&task.record, &task.record_path); + } + if current.incarnation.as_deref() != Some(task.record.incarnation.as_str()) { + return false; + } + match current.lifecycle { + TerminalLifecycle::Adopting => {} + TerminalLifecycle::Running => { + return match self.resolve_terminal(terminal_id) { + Ok(resolution) => { + resolution.is_some_and(|resolution| resolution.surface.is_some()) } - let _ = crate::terminal_host_runtime::remove_stale_terminal_host_record( - &record_path, - &record, - ); - break; - } - delay = (delay * 2).min(Duration::from_secs(5)); - continue; - } - if terminal_host_record_liveness(&record_path, &record) - == TerminalHostLiveness::Dead - { - if let Err(error) = mux.mark_terminal_exited_and_detach( - &terminal_id, - "terminal-host-proven-dead", - "host-process-ended-before-adoption", - &options, - ) { - eprintln!( - "cmux-tui: could not reconcile exited terminal \ - {terminal_id}: {error:#}" - ); - delay = (delay * 2).min(Duration::from_secs(5)); - continue; - } - let _ = crate::terminal_host_runtime::remove_stale_terminal_host_record( - &record_path, - &record, - ); - break; + Err(_) => false, + }; } - delay = (delay * 2).min(Duration::from_secs(5)); + TerminalLifecycle::Launching + | TerminalLifecycle::Exited + | TerminalLifecycle::Tombstoned => return false, } - mux.terminal_adoptions.lock().unwrap().remove(&terminal_id); - }); - if spawn_result.is_err() { - self.terminal_adoptions.lock().unwrap().remove(&cleanup_id); + } + } + if terminal_host_record_liveness(&task.record_path, &task.record) + == TerminalHostLiveness::Dead + { + let _ = crate::terminal_host_runtime::remove_stale_terminal_host_record( + &task.record_path, + &task.record, + ); + return self + .mark_terminal_exited_and_detach( + terminal_id, + "terminal-host-proven-dead", + "host-process-ended-before-adoption", + &task.options, + ) + .is_ok(); + } + let Ok(_creation) = self.begin_surface_creation() else { + return true; + }; + let mut owner_reservation = match self.reserve_surface_owner() { + Ok(reservation) => reservation, + Err(_) => return false, + }; + let restored_binding = match self.restored_terminal_binding(terminal_id) { + Ok(binding) => binding, + Err(_) => return false, + }; + #[cfg(test)] + let id = restored_binding + .as_ref() + .and_then(|binding| binding.placements.first().map(|(slot, _)| *slot)) + .unwrap_or_else(|| self.next_id()); + #[cfg(test)] + let adoption_surface_factory = + self.terminal_adoption_surface_factory.lock().unwrap().clone(); + #[cfg(test)] + let adopted = if let Some(factory) = adoption_surface_factory { + factory( + id, + TerminalHostIdentity { + terminal_id: task.record.terminal_id.clone(), + incarnation: task.record.incarnation.clone(), + }, + ) + } else { + self.adopt_restored_terminal( + restored_binding, + &task.options, + &task.record, + &task.record_path, + ) + }; + #[cfg(not(test))] + let adopted = self.adopt_restored_terminal( + restored_binding, + &task.options, + &task.record, + &task.record_path, + ); + let Ok(surface) = adopted else { return false }; + #[cfg(test)] + let after_attach = self.terminal_adoption_after_attach.lock().unwrap().clone(); + #[cfg(test)] + if let Some(hook) = after_attach { + hook(); + } + if self + .finish_terminal_adoption( + terminal_id, + &task.record.incarnation, + surface.clone(), + &mut owner_reservation, + ) + .is_ok() + { + self.reap_if_dead(&surface); + return true; } + let host_is_dead = surface.is_dead() + || terminal_host_record_liveness(&task.record_path, &task.record) + == TerminalHostLiveness::Dead; + surface.disconnect_for_daemon_shutdown(); + owner_reservation.release(); + if !host_is_dead { + return false; + } + let _ = crate::terminal_host_runtime::remove_stale_terminal_host_record( + &task.record_path, + &task.record, + ); + self.mark_terminal_exited_and_detach( + terminal_id, + "terminal-adoption-failed", + "host-adoption-topology-failed", + &task.options, + ) + .is_ok() } #[cfg(test)] @@ -3258,6 +4593,49 @@ impl Mux { self.next_id.fetch_add(1, Ordering::Relaxed) } + fn begin_surface_creation(&self) -> anyhow::Result> { + self.surface_creations.begin() + } + + fn ensure_surface_owner_capacity(&self) -> anyhow::Result<()> { + let state = self.state.lock().unwrap(); + self.ensure_surface_owner_capacity_with_state(&state) + } + + fn ensure_surface_owner_capacity_with_state(&self, state: &State) -> anyhow::Result<()> { + let used = state + .surfaces + .len() + .saturating_add(self.shutdown_owners.len()) + .saturating_add(self.surface_owner_reservations.load(Ordering::Acquire)); + if used >= self.shutdown_owner_capacity() { + anyhow::bail!("surface_owner_capacity_exhausted"); + } + Ok(()) + } + + fn reserve_surface_owner(&self) -> anyhow::Result> { + // Every topology removal transfers ownership to the shutdown ledger + // before releasing this same state lock. Counting reservations here + // therefore closes the only interval in which a new runtime could be + // spawned without an owner slot. + let state = self.state.lock().unwrap(); + self.ensure_surface_owner_capacity_with_state(&state)?; + self.surface_owner_reservations.fetch_add(1, Ordering::AcqRel); + drop(state); + Ok(SurfaceOwnerReservation { mux: self, active: true }) + } + + #[cfg(not(test))] + fn shutdown_owner_capacity(&self) -> usize { + SHUTDOWN_OWNER_CAPACITY + } + + #[cfg(test)] + fn shutdown_owner_capacity(&self) -> usize { + self.shutdown_owner_capacity.load(Ordering::Acquire).min(SHUTDOWN_OWNER_CAPACITY) + } + fn next_active_at(&self) -> u64 { self.next_active_at.fetch_add(1, Ordering::Relaxed) } @@ -5221,8 +6599,22 @@ impl Mux { self.workspace_registry.lock().unwrap().corrupt_agent_projection_for_test(terminal_id); } - pub fn terminal_registry_snapshot(&self) -> anyhow::Result { - self.workspace_registry.lock().unwrap().terminal_snapshot() + pub fn terminal_registry_snapshot(&self) -> anyhow::Result { + self.workspace_registry.lock().unwrap().terminal_snapshot() + } + + pub fn terminal_registry_revision(&self) -> anyhow::Result { + self.workspace_registry.lock().unwrap().terminal_revision() + } + + #[cfg(test)] + pub(crate) fn reset_terminal_snapshot_count_for_test(&self) { + self.workspace_registry.lock().unwrap().reset_terminal_snapshot_count_for_test(); + } + + #[cfg(test)] + pub(crate) fn terminal_snapshot_count_for_test(&self) -> usize { + self.workspace_registry.lock().unwrap().terminal_snapshot_count_for_test() } pub fn terminal_registry_events_page( @@ -5873,9 +7265,16 @@ impl Mux { { return false; } - surface.is_some_and(|surface| { + let reconnected = surface.is_some_and(|surface| { self.reconcile_reconnected_kitty_image_surface(&surface, applied_kitty_limits) - }) + }); + #[cfg(debug_assertions)] + if reconnected + && let Some(signal) = std::env::var_os("CMUX_TUI_TEST_TERMINAL_ADOPTED_SIGNAL") + { + let _ = std::fs::write(signal, b"1"); + } + reconnected } pub(crate) fn lock_client_sizing_lifecycle(&self) -> MutexGuard<'_, ()> { @@ -6047,6 +7446,7 @@ impl Mux { workspace_key: Option<&str>, reservation: Option, ) -> anyhow::Result> { + let mut owner_reservation = self.reserve_surface_owner()?; let id = self.next_id(); let mut opts = self.surface_options.lock().unwrap().clone(); if cwd.is_some() { @@ -6083,7 +7483,7 @@ impl Mux { #[cfg(all(not(test), unix))] let use_host_runtime = true; #[cfg(unix)] - if let (Some(_), Some(workspace_key), true) = + if let (Some(host_root), Some(workspace_key), true) = (opts.terminal_host_root.as_ref(), workspace_key, use_host_runtime) { let terminal_id = reservation @@ -6092,6 +7492,14 @@ impl Mux { .map(Ok) .unwrap_or_else(TerminalId::random)?; let terminal_hex = terminal_id.to_hex(); + // Acquire before the durable Launching commit. A replacement can + // therefore finalize record absence only after this parent dies + // before spawn or the inherited child finishes publication. + let publication_guard = + crate::terminal_host_runtime::acquire_terminal_host_publication( + host_root, + &terminal_hex, + )?; let launch_spec = terminal_launch_spec(&opts); let terminal = RegistryTerminal { terminal_id: terminal_hex.clone(), @@ -6139,7 +7547,8 @@ impl Mux { id, opts, Arc::downgrade(self), - Some(terminal_id), + terminal_id, + publication_guard, cell_pixels, ) { Ok(surface) => surface, @@ -6153,16 +7562,17 @@ impl Mux { } }; let _pending_host_release = PendingTerminalHostRelease(surface.clone()); - let identity = surface - .terminal_host_identity() - .ok_or_else(|| anyhow::anyhow!("reserved terminal did not return host identity"))?; + let Some(identity) = surface.terminal_host_identity() else { + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); + anyhow::bail!("reserved terminal did not return host identity"); + }; if identity.terminal_id != terminal_hex { let _ = self.persist_terminal_exit( &terminal_hex, None, &TerminalExit::unknown("host-identity-mismatch"), ); - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); anyhow::bail!("terminal host changed registry-reserved identity"); } let ready_revision = { @@ -6179,7 +7589,8 @@ impl Mux { let (_, ready_revision) = match ready { Ok(ready) => ready, Err(error) => { - surface.kill(); + drop(registry); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } }; @@ -6202,12 +7613,19 @@ impl Mux { "ready_revision":ready_revision, })), ); - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } }; - let insert_result = - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()); + let insert_result = { + let mut state = self.state.lock().unwrap(); + insert_reserved_surface_checked( + self, + &mut state, + surface.clone(), + &mut owner_reservation, + ) + }; drop(cell_pixel_lifecycle); if let Err(error) = insert_result { let _ = self.persist_terminal_exit( @@ -6215,7 +7633,7 @@ impl Mux { Some(&identity.incarnation), &TerminalExit::unknown("surface-insert-failed"), ); - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } // Deprecated recovery mirror only; SQLite is placement authority. @@ -6297,7 +7715,8 @@ impl Mux { ) { Ok(ready) => ready, Err(error) => { - surface.kill(); + drop(registry); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } }; @@ -6312,12 +7731,19 @@ impl Mux { Some(&incarnation), &TerminalExit::unknown(format!("cell-pixel-reconcile-failed: {error}")), ); - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } }; - let insert_result = - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()); + let insert_result = { + let mut state = self.state.lock().unwrap(); + insert_reserved_surface_checked( + self, + &mut state, + surface.clone(), + &mut owner_reservation, + ) + }; drop(cell_pixel_lifecycle); if let Err(error) = insert_result { let _ = self.persist_terminal_exit( @@ -6325,7 +7751,7 @@ impl Mux { Some(&incarnation), &TerminalExit::unknown("surface-insert-failed"), ); - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } self.reserved_in_process_terminals.lock().unwrap().insert(surface.id, identity); @@ -6351,16 +7777,23 @@ impl Mux { Ok(lifecycle) => lifecycle, Err(error) => { self.pending_workspace_surfaces.lock().unwrap().remove(&id); - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } }; - let insert_result = - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()); + let insert_result = { + let mut state = self.state.lock().unwrap(); + insert_reserved_surface_checked( + self, + &mut state, + surface.clone(), + &mut owner_reservation, + ) + }; drop(cell_pixel_lifecycle); if let Err(error) = insert_result { self.pending_workspace_surfaces.lock().unwrap().remove(&id); - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } Ok(surface) @@ -6374,6 +7807,7 @@ impl Mux { if options.command.is_empty() { anyhow::bail!("sidebar plugin command is empty"); } + let mut owner_reservation = self.reserve_surface_owner()?; let id = self.next_id(); let mut opts = self.surface_options.lock().unwrap().clone(); opts.command = Some(options.command.clone()); @@ -6404,15 +7838,22 @@ impl Mux { let cell_pixel_lifecycle = match self.reconcile_surface_cell_pixels_for_publish(&surface) { Ok(lifecycle) => lifecycle, Err(error) => { - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } }; - let insert_result = - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()); + let insert_result = { + let mut state = self.state.lock().unwrap(); + insert_reserved_surface_checked( + self, + &mut state, + surface.clone(), + &mut owner_reservation, + ) + }; drop(cell_pixel_lifecycle); if let Err(error) = insert_result { - surface.kill(); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); return Err(error); } Ok(surface) @@ -6425,6 +7866,7 @@ impl Mux { pending_workspace: Option, resource_identity: Option, ) -> anyhow::Result> { + let mut owner_reservation = self.reserve_surface_owner()?; let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); let id = self.next_id(); if let Some(workspace) = pending_workspace { @@ -6452,7 +7894,20 @@ impl Mux { Arc::downgrade(self), )?, }; - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone())?; + let insertion = { + let mut state = self.state.lock().unwrap(); + insert_reserved_surface_checked( + self, + &mut state, + surface.clone(), + &mut owner_reservation, + ) + }; + if let Err(error) = insertion { + self.pending_workspace_surfaces.lock().unwrap().remove(&id); + self.retire_reserved_surface_runtime(surface, &mut owner_reservation); + return Err(error); + } let tab_id = surface .resource_identity() .context("browser surface omitted its public tab identity")? @@ -6460,8 +7915,7 @@ impl Mux { .clone(); self.start_browser_bootstrap( surface.clone(), - BrowserBootstrap::Provider { tab_id, url }, - None, + BrowserSurfaceBootstrap::Provider { tab_id, url }, ); Ok(surface) } @@ -7403,6 +8857,11 @@ impl Mux { *self.terminal_move_before_projection.lock().unwrap() = hook; } + #[cfg(test)] + fn set_shutdown_owner_capacity_for_test(&self, capacity: usize) { + self.shutdown_owner_capacity.store(capacity, Ordering::Release); + } + #[cfg(test)] fn last_resource_mutation_metrics(&self) -> ResourceMutationMetrics { self.resource_mutation_metrics @@ -7418,6 +8877,7 @@ impl Mux { incarnation: &str, workspace_key: &str, ) -> anyhow::Result { + let _creation = self.begin_surface_creation()?; let mut registry = self.workspace_registry.lock().unwrap(); commit_terminal_transition( &mut registry, @@ -7451,7 +8911,7 @@ impl Mux { }, )?; let mut state = self.state.lock().unwrap(); - insert_surface_checked(&mut state, surface.clone())?; + insert_surface_checked(self, &mut state, surface.clone())?; let (placement, changed) = self.project_terminal_to_workspace_in_state(&mut state, terminal_id, workspace_key)?; anyhow::ensure!(changed, "seeded terminal did not change topology"); @@ -7470,19 +8930,6 @@ impl Mux { self.workspace_registry.lock().unwrap().set_terminal_close_failure(enabled) } - fn browser_runtime(&self) -> anyhow::Result> { - let mut runtime = self.browser_runtime.lock().unwrap(); - if let Some(existing) = runtime.as_ref().filter(|existing| { - !existing.is_closed() && existing.source() != crate::BrowserSource::Provider - }) { - return Ok(existing.clone()); - } - let opts = self.surface_options.lock().unwrap().clone(); - let created = BrowserRuntime::connect(&opts)?; - *runtime = Some(created.clone()); - Ok(created) - } - pub(crate) fn register_browser_provider( self: &Arc, client: u64, @@ -7531,25 +8978,29 @@ impl Mux { &self, lease: &BrowserProviderTargetLease, ) -> anyhow::Result> { - let mut runtime = self.browser_runtime.lock().unwrap(); - if let Some(existing) = runtime.as_ref().filter(|existing| { - !existing.is_closed() - && existing.matches_provider(&lease.endpoint, &lease.authentication) - }) { - return Ok(existing.clone()); - } - let created = BrowserRuntime::connect_provider(&lease.endpoint, &lease.authentication)?; - *runtime = Some(created.clone()); - Ok(created) + self.browser_runtime.get_or_connect( + || self.is_shutting_down(), + |runtime| { + !runtime.is_closed() + && runtime.matches_provider(&lease.endpoint, &lease.authentication) + }, + || BrowserRuntime::connect_provider(&lease.endpoint, &lease.authentication), + ) } fn start_browser_bootstrap( self: &Arc, surface: Arc, - bootstrap: BrowserBootstrap, - runtime: Option>, + bootstrap: BrowserSurfaceBootstrap, ) { - let provider_bootstrap = matches!(&bootstrap, BrowserBootstrap::Provider { .. }); + let bootstrap_guard = match self.async_surface_creations.begin() { + Ok(guard) => guard, + Err(error) => { + self.report_browser_bootstrap_failure(&surface, error.to_string()); + return; + } + }; + let provider_bootstrap = matches!(&bootstrap, BrowserSurfaceBootstrap::Provider { .. }); let weak_mux = Arc::downgrade(self); let providers = self.browser_providers.clone(); let id = surface.id; @@ -7557,13 +9008,10 @@ impl Mux { let spawn = std::thread::Builder::new() .name(format!("browser-surface-{id}-bootstrap")) .spawn(move || { + let _bootstrap_guard = bootstrap_guard; let result = (|| -> anyhow::Result<()> { match bootstrap { - BrowserBootstrap::Provider { tab_id, url } => { - anyhow::ensure!( - runtime.is_none(), - "provider bootstrap cannot override its CDP runtime" - ); + BrowserSurfaceBootstrap::Provider { tab_id, url } => { let mut retry_delay = Duration::from_millis(250); loop { let canceled = || { @@ -7592,10 +9040,8 @@ impl Mux { ); runtime.bootstrap_surface_sync( thread_surface.clone(), - BrowserBootstrap::ExistingTarget { - target_id: lease.target_id.clone(), - url: url.clone(), - }, + lease.target_id.clone(), + url.clone(), weak_mux.clone(), ) })(); @@ -7657,17 +9103,14 @@ impl Mux { } } } - bootstrap => { - let mux = weak_mux + BrowserSurfaceBootstrap::ExistingTarget { runtime, target_id, url } => { + let _mux = weak_mux .upgrade() .ok_or_else(|| anyhow::anyhow!("browser mux was dropped"))?; - let runtime = match runtime { - Some(runtime) => runtime, - None => mux.browser_runtime()?, - }; runtime.bootstrap_surface_sync( thread_surface.clone(), - bootstrap, + target_id, + url, weak_mux.clone(), ) } @@ -7701,6 +9144,15 @@ impl Mux { } } + fn report_browser_bootstrap_failure(&self, surface: &Arc, error: String) { + if let Surface::Browser(browser) = surface.as_ref() { + browser.mark_failed(error.clone()); + } + self.emit(MuxEvent::Status(format!("browser failed: {error}"))); + self.emit(MuxEvent::TitleChanged { surface: surface.id, title: surface.title().into() }); + self.emit(MuxEvent::SurfaceOutput(surface.id)); + } + pub(crate) fn restart_provider_browser_surface(self: &Arc, surface: Arc) { let Some(identity) = surface.resource_identity() else { return }; if !matches!(identity.content_id, ContentPublicId::Browser(_)) { @@ -7708,7 +9160,7 @@ impl Mux { } let tab_id = identity.tab_id.clone(); let url = surface.browser_url().unwrap_or_else(|| "about:blank".to_string()); - self.start_browser_bootstrap(surface, BrowserBootstrap::Provider { tab_id, url }, None); + self.start_browser_bootstrap(surface, BrowserSurfaceBootstrap::Provider { tab_id, url }); } /// A fresh single-tab pane wrapping `surface`. @@ -7912,10 +9364,13 @@ impl Mux { for screen in changed_screens { self.emit(MuxEvent::LayoutChanged(screen)); } - if !had_runtime { - self.terminate_discovered_terminal_host(terminal_id, terminal_incarnation.as_deref()); - } + let termination = if !had_runtime { + self.terminate_discovered_terminal_host(terminal_id, terminal_incarnation.as_deref()) + } else { + Ok(()) + }; self.emit_empty_if_current(empty_revision); + termination?; Ok(TerminalCloseResult { surface: target, terminal_id: terminal_id.to_string(), @@ -7934,25 +9389,13 @@ impl Mux { _operation: &str, reason: &str, ) -> anyhow::Result<()> { - let Some(identity) = self.resource_terminal_host_identity(surface) else { - let removed = { - let mut state = self.state.lock().unwrap(); - remove_terminal_runtime_from_state(self, &mut state, surface).0 - }; - for placement in removed { - self.purge_surface_side_tables(placement.id); - } - self.purge_terminal_runtime_side_tables(surface); - if !surface.is_dead() { - surface.kill(); - } - return Ok(()); - }; - self.persist_terminal_exit( - &identity.terminal_id, - Some(&identity.incarnation), - &TerminalExit::unknown(reason), - )?; + if let Some(identity) = self.resource_terminal_host_identity(surface) { + self.persist_terminal_exit( + &identity.terminal_id, + Some(&identity.incarnation), + &TerminalExit::unknown(reason), + )?; + } let removed = { let mut state = self.state.lock().unwrap(); remove_terminal_runtime_from_state(self, &mut state, surface).0 @@ -7961,9 +9404,7 @@ impl Mux { self.purge_surface_side_tables(placement.id); } self.purge_terminal_runtime_side_tables(surface); - if !surface.is_dead() { - surface.kill(); - } + self.retire_surface_runtime(surface.clone()); Ok(()) } @@ -7971,28 +9412,207 @@ impl Mux { &self, terminal_id: &str, incarnation: Option<&str>, - ) { + ) -> anyhow::Result<()> { + self.terminate_discovered_terminal_hosts(&[( + terminal_id.to_string(), + incarnation.map(str::to_string), + )]) + } + + fn terminate_discovered_terminal_hosts( + &self, + terminals: &[(String, Option)], + ) -> anyhow::Result<()> { + if terminals.is_empty() { + return Ok(()); + } + #[cfg(test)] + self.discovered_terminal_termination_requests + .lock() + .unwrap() + .extend(terminals.iter().cloned()); #[cfg(unix)] { let root = self.surface_options.lock().unwrap().terminal_host_root.clone(); - let Some(root) = root else { return }; - let Ok(records) = crate::terminal_host_runtime::load_terminal_host_records(&root) - else { - return; - }; + let Some(root) = root else { return Ok(()) }; + for (terminal_id, expected_incarnation) in terminals { + let Some((path, record)) = + crate::terminal_host_runtime::load_terminal_host_record(&root, terminal_id) + .with_context(|| { + format!("load exact terminal-host record for {terminal_id}") + })? + else { + continue; + }; + anyhow::ensure!( + expected_incarnation + .as_deref() + .is_none_or(|expected| record.incarnation == expected), + "terminal-host incarnation changed while closing {terminal_id}" + ); + anyhow::ensure!( + cleanup_terminal_host_record(&record, &path), + "could not clean up terminal host {terminal_id}" + ); + } + Ok(()) + } + #[cfg(not(unix))] + { + let _ = terminals; + Ok(()) + } + } + + #[cfg(test)] + pub(crate) fn take_discovered_terminal_termination_requests_for_test( + &self, + ) -> Vec<(String, Option)> { + std::mem::take(&mut *self.discovered_terminal_termination_requests.lock().unwrap()) + } + + fn prepare_terminal_host_cleanup_at_root( + &self, + root: Option<&Path>, + terminals: &[(String, Option)], + ) -> anyhow::Result { + anyhow::ensure!( + terminals.len() <= MAX_TERMINAL_HOST_CLEANUP_IDENTITIES, + "terminal-host cleanup target exceeds capacity" + ); + let mut seen = HashSet::with_capacity(terminals.len()); + for (terminal_id, incarnation) in terminals { + validate_terminal_hex(terminal_id, "terminal-host cleanup id is invalid")?; + anyhow::ensure!( + seen.insert(terminal_id.as_str()), + "terminal-host cleanup target contains a duplicate id" + ); + if let Some(incarnation) = incarnation { + validate_terminal_hex(incarnation, "terminal-host cleanup incarnation is invalid")?; + } + } + #[cfg(unix)] + { + let Some(root) = root else { return Ok(Vec::new()) }; + let mut records = Vec::new(); + for (terminal_id, expected_incarnation) in terminals { + let Some((path, record)) = + crate::terminal_host_runtime::load_terminal_host_record(root, terminal_id) + .with_context(|| { + format!("load exact terminal-host record for {terminal_id}") + })? + else { + continue; + }; + anyhow::ensure!( + expected_incarnation + .as_deref() + .is_none_or(|expected| record.incarnation == expected), + "terminal-host incarnation changed while closing {terminal_id}" + ); + records.push((path, record)); + } + #[cfg(test)] + self.discovered_terminal_termination_requests.lock().unwrap().extend( + records.iter().map(|(_, record)| { + (record.terminal_id.clone(), Some(record.incarnation.clone())) + }), + ); + Ok(records) + } + #[cfg(not(unix))] + { + let _ = root; + let _ = terminals; + Ok(()) + } + } + + fn terminate_prepared_terminal_hosts( + &self, + records: TerminalHostRecords, + ) -> anyhow::Result<()> { + #[cfg(test)] + self.run_terminal_host_cleanup_hook()?; + #[cfg(unix)] + { for (path, record) in records { - if record.terminal_id == terminal_id - && incarnation.is_none_or(|expected| record.incarnation == expected) - && !terminate_host_record(record.clone(), path.clone()) + anyhow::ensure!( + cleanup_terminal_host_record(&record, &path), + "could not clean up terminal host {}", + record.terminal_id + ); + if let Some((exit_path, exit)) = + crate::terminal_host_runtime::terminal_host_exit_record(&path)? { - schedule_terminal_host_record_cleanup(record, path); + anyhow::ensure!( + exit.terminal_id == record.terminal_id + && exit.incarnation == record.incarnation, + "terminal-host exit identity changed while closing {}", + record.terminal_id + ); + anyhow::ensure!( + acknowledge_exact_terminal_host_exit(&exit_path, &exit), + "could not acknowledge terminal-host exit {}", + record.terminal_id + ); } } - let record_path = root.join(format!("{terminal_id}.json")); - let _ = acknowledge_terminal_exit_sidecar(&record_path, terminal_id, incarnation); + Ok(()) } #[cfg(not(unix))] - let _ = (terminal_id, incarnation); + { + let _ = records; + Ok(()) + } + } + + pub(crate) fn terminate_workspace_hosts_from_receipt( + &self, + result: &Value, + ) -> anyhow::Result<()> { + #[cfg(test)] + self.run_terminal_host_cleanup_hook()?; + let identities = terminal_host_cleanup_identities(result)?; + let mut runtimes = Vec::new(); + let mut removed = Vec::new(); + let mut missing = Vec::new(); + { + let mut state = self.state.lock().unwrap(); + for (terminal_id, expected_incarnation) in identities { + let Some(runtime) = self.catalog_terminal_by_host(&state, &terminal_id)? else { + missing.push((terminal_id, expected_incarnation)); + continue; + }; + let identity = self + .resource_terminal_host_identity(&runtime) + .context("catalog terminal has no host identity")?; + anyhow::ensure!( + expected_incarnation + .as_deref() + .is_none_or(|expected| identity.incarnation == expected), + "terminal-host incarnation changed while closing {terminal_id}" + ); + removed.extend(remove_terminal_runtime_from_state(self, &mut state, &runtime).0); + runtimes.push(runtime); + } + } + for placement in removed { + self.purge_surface_side_tables(placement.id); + } + for runtime in runtimes { + self.purge_terminal_runtime_side_tables(&runtime); + self.terminate_terminal_runtime(&runtime); + } + self.terminate_discovered_terminal_hosts(&missing) + } + + #[cfg(test)] + fn run_terminal_host_cleanup_hook(&self) -> anyhow::Result<()> { + if let Some(hook) = self.terminal_host_cleanup_hook.lock().unwrap().clone() { + anyhow::ensure!(hook(), "forced terminal-host cleanup failure"); + } + Ok(()) } fn terminate_terminal_runtime(&self, runtime: &Arc) { @@ -8016,7 +9636,7 @@ impl Mux { runtime.kill(); #[cfg(unix)] if !acknowledged && let Some(identity) = identity { - self.terminate_discovered_terminal_host( + let _ = self.terminate_discovered_terminal_host( &identity.terminal_id, Some(&identity.incarnation), ); @@ -8476,6 +10096,138 @@ impl Mux { } } + /// Finish removed runtimes outside the topology lock. One shared deadline + /// bounds an entire pane/workspace close, and failures retain only the + /// minimal process/target owner needed for a later retry. + fn retire_surface_runtime(&self, surface: Arc) { + self.retire_surface_runtimes([surface]); + } + + /// Transfer an uninserted runtime from its admission reservation into the + /// retry ledger without opening a capacity-accounting gap. + fn retire_reserved_surface_runtime( + &self, + surface: Arc, + reservation: &mut SurfaceOwnerReservation<'_>, + ) { + surface.release_kitty_image_budget(); + let owner = { + let _state = self.state.lock().unwrap(); + let owner = self.shutdown_owners.stage_surface(&surface); + reservation.release(); + owner + }; + let Some(owner) = owner else { return }; + let deadline = Instant::now() + SHUTDOWN_TERMINATION_TIMEOUT; + let Ok(_coordinator) = self.lock_shutdown_coordinator_until(deadline) else { + self.shutdown_owner_reconciler.schedule(); + return; + }; + if self.terminate_shutdown_owners_until(vec![owner], deadline) { + self.shutdown_owner_reconciler.schedule(); + } + } + + fn retire_surface_runtimes(&self, surfaces: impl IntoIterator>) { + let surfaces = surfaces.into_iter().collect::>(); + for surface in &surfaces { + surface.release_kitty_image_budget(); + } + let owners = surfaces + .into_iter() + .filter_map(|surface| self.shutdown_owners.stage_surface(&surface)) + .collect::>(); + let deadline = Instant::now() + SHUTDOWN_TERMINATION_TIMEOUT; + let Ok(_coordinator) = self.lock_shutdown_coordinator_until(deadline) else { + self.shutdown_owner_reconciler.schedule(); + return; + }; + if self.terminate_shutdown_owners_until(owners, deadline) { + self.shutdown_owner_reconciler.schedule(); + } + } + + fn terminate_staged_shutdown_owners_until(&self, deadline: Instant) -> bool { + let owners = self.shutdown_owners.snapshot(); + self.terminate_shutdown_owners_until(owners, deadline) + } + + fn terminate_shutdown_owners_until( + &self, + owners: Vec<(ShutdownOwnerKey, Arc)>, + deadline: Instant, + ) -> bool { + if owners.is_empty() { + return false; + } + #[cfg(unix)] + let process_snapshot = crate::process_session::SessionProcessSnapshot::capture( + owners.iter().filter_map(|(_, owner)| owner.local_process_session()), + deadline, + ) + .ok(); + let mut single_work = Vec::new(); + let mut browser_batches = HashMap::< + *const BrowserRuntime, + (Arc, Vec<(ShutdownOwnerKey, Arc)>), + >::new(); + for (key, owner) in &owners { + if let Some(runtime) = owner.browser_runtime() { + browser_batches + .entry(Arc::as_ptr(runtime)) + .or_insert_with(|| (runtime.clone(), Vec::new())) + .1 + .push((key.clone(), owner.clone())); + } else { + single_work.push(ShutdownOwnerWork::Single((key.clone(), owner.clone()))); + } + } + // Start each high-fan-in browser batch before individual process work, + // so a saturated fanout cannot strand every target behind the deadline. + let mut work = browser_batches + .into_values() + .map(|(runtime, owners)| ShutdownOwnerWork::BrowserBatch { runtime, owners }) + .collect::>(); + work.extend(single_work); + + let failed = Mutex::new(HashSet::new()); + let started = bounded_shutdown_fanout(&work, deadline, |work, deadline| match work { + ShutdownOwnerWork::Single((key, owner)) => { + #[cfg(unix)] + let terminated = + owner.terminate_until_in_batch(deadline, process_snapshot.as_ref()); + #[cfg(not(unix))] + let terminated = owner.terminate_until(deadline); + if !terminated { + failed.lock().unwrap().insert(key.clone()); + } + } + ShutdownOwnerWork::BrowserBatch { runtime, owners } => { + let browser_owners = owners + .iter() + .map(|(_, owner)| { + owner.browser_shutdown_owner().expect("browser batch has browser owners") + }) + .collect::>(); + let outcomes = runtime.close_owners_for_shutdown(&browser_owners, deadline); + let mut failed = failed.lock().unwrap(); + for (index, (key, _)) in owners.iter().enumerate() { + if !outcomes.get(index).copied().unwrap_or(false) { + failed.insert(key.clone()); + } + } + } + }); + let mut failed = failed.into_inner().unwrap(); + for work in &work[started..] { + work.record_failed_keys(&mut failed); + } + let confirmed = + owners.into_iter().filter(|(key, _)| !failed.contains(key)).collect::>(); + self.shutdown_owners.remove_confirmed(&confirmed); + !failed.is_empty() + } + pub fn list_agents( &self, surface: Option, @@ -8522,16 +10274,85 @@ impl Mux { .collect() } - pub fn shutdown(&self) { - self.shutting_down.store(true, Ordering::Release); + fn shutdown_browser_runtimes_until(&self, deadline: Instant) -> bool { + let retained = self.shutdown_owners.snapshot(); + let current_browser_runtime = self.browser_runtime.take_for_shutdown(); + let mut browser_runtimes = Vec::new(); + for runtime in retained.iter().filter_map(|(_, owner)| owner.browser_runtime()) { + if !browser_runtimes.iter().any(|candidate| Arc::ptr_eq(candidate, runtime)) { + browser_runtimes.push(runtime.clone()); + } + } + if let Some(runtime) = ¤t_browser_runtime + && !browser_runtimes.iter().any(|candidate| Arc::ptr_eq(candidate, runtime)) + { + browser_runtimes.push(runtime.clone()); + } + + let mut failed = false; + for runtime in browser_runtimes { + let is_current = current_browser_runtime + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &runtime)); + let surface_failed = retained.iter().any(|(_, owner)| { + owner + .browser_runtime() + .is_some_and(|owner_runtime| Arc::ptr_eq(owner_runtime, &runtime)) + }); + if surface_failed && runtime.source() == BrowserSource::External { + if is_current { + self.browser_runtime.restore_for_shutdown(runtime); + } + continue; + } + if runtime.shutdown_until(deadline) { + if runtime.source() == BrowserSource::Launched { + self.shutdown_owners.remove_browser_runtime(&runtime); + } + } else { + failed = true; + if is_current { + self.browser_runtime.restore_for_shutdown(runtime); + } + } + } + failed + } + + fn shutdown_once_until(&self, deadline: Instant) -> anyhow::Result<()> { + #[cfg(unix)] + crate::process_session::require_stable_process_signaling_until(deadline) + .context("preflight process control for daemon exit")?; + let _coordinator = self.lock_shutdown_coordinator_until(deadline)?; + self.browser_runtime.stop_with_fence(|| self.shutting_down.store(true, Ordering::Release)); self.config_reload_changed.notify_all(); self.journal_kernel.wake_waiters(); - let hook_deadline = Instant::now() + crate::journal_hooks::SHUTDOWN_WAIT; + let hook_deadline = deadline.min(Instant::now() + crate::journal_hooks::SHUTDOWN_WAIT); if !self.journal_hook_runtime.shutdown_until(hook_deadline) { eprintln!("cmux-tui: journal hook workers did not stop before the shutdown deadline"); } + #[cfg(unix)] + self.request_terminal_adoption_stop(); + if !self.surface_creations.stop_and_wait_until(deadline) { + anyhow::bail!("surface creation remains active at the daemon exit deadline"); + } + if !self.async_surface_creations.stop_and_wait_until(deadline) { + anyhow::bail!("browser bootstrap remains active at the daemon exit deadline"); + } + #[cfg(unix)] + if !self.wait_for_terminal_adoption_workers_until(deadline) { + anyhow::bail!("terminal adoption remains active at the daemon exit deadline"); + } + #[cfg(unix)] + crate::process_session::wait_for_unpublished_child_reaper_until(deadline) + .context("drain unpublished child reaper ownership for daemon exit")?; + let surfaces = unique_surface_runtimes(&self.state.lock().unwrap()); - let terminal_reader_deadline = Instant::now() + TERMINAL_READER_SHUTDOWN_TIMEOUT; + for surface in &surfaces { + let _ = self.shutdown_owners.stage_local_process_surface(surface); + } + let terminal_reader_deadline = + deadline.min(Instant::now() + TERMINAL_READER_SHUTDOWN_TIMEOUT); let mut terminal_gaps = surfaces .iter() .filter_map(|surface| surface.shutdown_for_daemon(terminal_reader_deadline)) @@ -8551,11 +10372,6 @@ impl Mux { eprintln!("cmux-tui: record terminal output gap during shutdown: {error:#}"); } } - // Each terminal reader has drained or its journal capture gate has - // closed. An update that exceeded the extra active-update grace has a - // durable gap above. Fence the terminal ingress lane while this Mux - // still owns the registry; the closed gate prevents a timed-out reader - // from inserting output after the barrier. if let Err(error) = self.flush_terminal_journal() { eprintln!("cmux-tui: flush terminal journal during shutdown: {error:#}"); } @@ -8563,11 +10379,483 @@ impl Mux { eprintln!("cmux-tui: stop session journal writer during shutdown: {error:#}"); } self.journal_kernel.shutdown(); - if let Some(runtime) = self.browser_runtime.lock().unwrap().take() { - runtime.shutdown(); + + self.terminate_staged_shutdown_owners_until(deadline); + let browser_runtime_failed = self.shutdown_browser_runtimes_until(deadline); + let retained = self.shutdown_owners.len(); + if retained != 0 || browser_runtime_failed { + anyhow::bail!( + "daemon exit retained {retained} surface owner(s); browser runtime failure: \ + {browser_runtime_failed}" + ); + } + Ok(()) + } + + /// Finish every fallible cleanup phase before allowing the server process + /// to release its socket. Retried work keeps exact ownership, while one + /// absolute deadline guarantees an accepted daemon handoff cannot wedge + /// the old process or its socket forever. + pub fn shutdown(&self) -> anyhow::Result<()> { + self.shutdown_until(Instant::now() + self.shutdown_total_timeout()) + } + + /// Finish cleanup within a caller-owned absolute deadline so surrounding + /// process owners and mux resources share one bounded handoff window. + pub fn shutdown_until(&self, overall_deadline: Instant) -> anyhow::Result<()> { + let cleanup_lifecycle = self.shutdown_cleanup_lifecycle.begin(); + self.shutdown_owner_reconciler.stop(); + let mut retry_delay = SERVER_EXIT_RETRY_INITIAL_DELAY; + let mut last_error = None; + loop { + let now = Instant::now(); + if now >= overall_deadline { + break; + } + let attempt_deadline = (now + self.shutdown_attempt_timeout()).min(overall_deadline); + match self.shutdown_once_until(attempt_deadline) { + Ok(()) => { + cleanup_lifecycle.complete(); + return Ok(()); + } + Err(error) => last_error = Some(error), + } + self.shutdown_owner_reconciler.wait_for_retry(retry_delay, overall_deadline); + retry_delay = retry_delay.saturating_mul(2).min(SERVER_EXIT_RETRY_MAX_DELAY); + } + let error = + last_error.unwrap_or_else(|| anyhow::anyhow!("daemon cleanup deadline expired")); + Err(error.context("daemon cleanup did not finish before the process exit deadline")) + } + + fn shutdown_attempt_timeout(&self) -> Duration { + #[cfg(test)] + if let Some(timeout) = *self.shutdown_attempt_timeout.lock().unwrap() { + return timeout; + } + crate::server::SERVER_SHUTDOWN_TIMEOUT + } + + fn shutdown_total_timeout(&self) -> Duration { + #[cfg(test)] + if let Some(timeout) = *self.shutdown_attempt_timeout.lock().unwrap() { + return timeout.saturating_mul(SERVER_EXIT_TEST_ATTEMPT_BUDGET); + } + crate::server::SERVER_SHUTDOWN_TIMEOUT + } + + #[cfg(test)] + fn set_shutdown_attempt_timeout_for_test(&self, timeout: Duration) { + *self.shutdown_attempt_timeout.lock().unwrap() = Some(timeout); + } + + pub fn request_shutdown(&self) { + self.shutdown_requested.store(true, Ordering::Release); + let watchers = { + let mut watchers = self.shutdown_request_watchers.lock().unwrap(); + watchers.drain(..).filter_map(|watcher| watcher.upgrade()).collect::>() + }; + for watcher in watchers { + ShutdownRequestWatch { inner: watcher }.request(); + } + } + + pub fn shutdown_requested(&self) -> bool { + self.shutdown_requested.load(Ordering::Acquire) + } + + pub fn watch_shutdown_request(&self) -> ShutdownRequestWatch { + let inner = Arc::new(ShutdownRequestWatchInner::default()); + let watch = ShutdownRequestWatch { inner: inner.clone() }; + let mut watchers = self.shutdown_request_watchers.lock().unwrap(); + watchers.retain(|watcher| watcher.strong_count() != 0); + if self.shutdown_requested.load(Ordering::Acquire) { + watch.request(); + } else { + watchers.push(Arc::downgrade(&inner)); + } + watch + } + + /// Observe the next shutdown request, even after the server has already + /// accepted an earlier request. Degraded process cleanup uses this to + /// require explicit operator intent before each bounded retry. + pub fn watch_next_shutdown_request(&self) -> ShutdownRequestWatch { + let inner = Arc::new(ShutdownRequestWatchInner::default()); + let watch = ShutdownRequestWatch { inner: inner.clone() }; + let mut watchers = self.shutdown_request_watchers.lock().unwrap(); + watchers.retain(|watcher| watcher.strong_count() != 0); + watchers.push(Arc::downgrade(&inner)); + watch + } + + /// Publish an outer server cleanup attempt through the same health state + /// used by mux-owned shutdown work. Server process owners live outside the + /// mux, but clients must observe one authoritative cleanup lifecycle. + pub fn schedule_server_shutdown_cleanup(&self) { + self.shutdown_cleanup_lifecycle.schedule(); + } + + /// Clear the shared cleanup health after every mux and outer server owner + /// has completed shutdown. + pub fn complete_server_shutdown_cleanup(&self) { + self.shutdown_cleanup_lifecycle.complete(); + } + + /// Publish an outer server cleanup failure that requires explicit retry. + pub fn degrade_server_shutdown_cleanup(&self) { + self.shutdown_cleanup_lifecycle.degrade(); + } + + pub(crate) fn shutdown_cleanup_health(&self) -> ShutdownCleanupHealth { + let owner_pending = self.shutdown_owners.len(); + #[cfg(unix)] + let unpublished_reaper = crate::process_session::unpublished_child_reaper_health(); + #[cfg(unix)] + let (unpublished_pending, unpublished_degraded) = + (unpublished_reaper.pending, unpublished_reaper.degraded); + #[cfg(not(unix))] + let (unpublished_pending, unpublished_degraded) = (0, false); + let lifecycle = self.shutdown_cleanup_lifecycle.snapshot(); + let state = self.shutdown_owner_reconciler.state.lock().unwrap(); + let synchronous_retry = lifecycle.pending && lifecycle.retrying && !lifecycle.degraded; + let owner_retrying = + owner_pending != 0 && (synchronous_retry || (state.worker_started && !state.degraded)); + let owner_degraded = + owner_pending != 0 && !synchronous_retry && (state.degraded || !state.worker_started); + let degraded = lifecycle.degraded || owner_degraded || unpublished_degraded; + ShutdownCleanupHealth { + pending: owner_pending + .saturating_add(unpublished_pending) + .max(usize::from(lifecycle.pending)), + retrying: !degraded + && (lifecycle.retrying || owner_retrying || unpublished_pending != 0), + degraded, + } + } + + /// Permanently fence new surface creation, tombstone every durable + /// terminal in one transaction, detach the complete topology in one + /// state mutation, and terminate each runtime before acknowledging the + /// shutdown request. A failed durable commit leaves topology untouched; + /// a failed runtime termination keeps process ownership and the fence + /// closed so a retry cannot race a new PTY into the session. + pub fn close_all_surfaces_for_shutdown(&self) -> anyhow::Result { + let deadline = Instant::now() + crate::server::SERVER_SHUTDOWN_TIMEOUT; + let result = self.close_all_surfaces_for_shutdown_until(deadline); + #[cfg(debug_assertions)] + if let Err(error) = &result + && let Some(path) = std::env::var_os("CMUX_TUI_TEST_SHUTDOWN_OWNER_DIAGNOSTIC") + { + let _ = std::fs::write(path, format!("{error:#}")); + } + result + } + + fn close_all_surfaces_for_shutdown_until(&self, deadline: Instant) -> anyhow::Result { + #[cfg(unix)] + crate::process_session::require_stable_process_signaling_until(deadline) + .context("preflight process control for server shutdown")?; + let _coordinator = self.lock_shutdown_coordinator_until(deadline)?; + let cleanup_lifecycle = self.shutdown_cleanup_lifecycle.begin(); + self.browser_runtime.stop_with_fence(|| self.shutting_down.store(true, Ordering::Release)); + #[cfg(unix)] + self.request_terminal_adoption_stop(); + if !self.surface_creations.stop_and_wait_until(deadline) { + anyhow::bail!("surface creation did not stop before the shutdown deadline"); + } + if !self.async_surface_creations.stop_and_wait_until(deadline) { + anyhow::bail!("browser bootstrap did not stop before the shutdown deadline"); + } + #[cfg(unix)] + if !self.wait_for_terminal_adoption_workers_until(deadline) { + anyhow::bail!("terminal adoption did not stop before the shutdown deadline"); + } + #[cfg(unix)] + crate::process_session::wait_for_unpublished_child_reaper_until(deadline) + .context("drain unpublished child reaper ownership for server shutdown")?; + + #[cfg(unix)] + let terminal_host_records = { + let root = self.surface_options.lock().unwrap().terminal_host_root.clone(); + let capacity = self.shutdown_owner_capacity(); + let (required, runtime_owner_keys) = { + let state = self.state.lock().unwrap(); + let required = state + .surfaces + .values() + .filter(|surface| !surface.is_dead()) + .filter_map(|surface| surface.terminal_host_identity()) + .collect::>(); + let runtime_owner_keys = unique_surface_runtimes(&state) + .into_iter() + .filter_map(|surface| match surface.shutdown_owner_identity()? { + SurfaceShutdownOwnerIdentity::Surface => { + Some(ShutdownOwnerKey::Surface(surface.id)) + } + SurfaceShutdownOwnerIdentity::Hosted { terminal_id, incarnation } => { + Some(ShutdownOwnerKey::Hosted { terminal_id, incarnation }) + } + }) + .collect::>(); + (required, runtime_owner_keys) + }; + let mut owner_keys = self + .shutdown_owners + .snapshot() + .into_iter() + .map(|(key, _)| key) + .collect::>(); + owner_keys.extend(runtime_owner_keys); + let records = match root { + Some(root) => { + let records = self + .load_terminal_host_records_for_shutdown(root, capacity, deadline) + .context("load terminal hosts for server shutdown")?; + let available = records + .iter() + .map(|(_, record)| { + (record.terminal_id.as_str(), record.incarnation.as_str()) + }) + .collect::>(); + for identity in required { + if !available.contains(&( + identity.terminal_id.as_str(), + identity.incarnation.as_str(), + )) { + anyhow::bail!( + "load terminal hosts for server shutdown: missing record for {}:{}", + identity.terminal_id, + identity.incarnation + ); + } + } + records + } + None if required.is_empty() => Vec::new(), + None => anyhow::bail!( + "load terminal hosts for server shutdown: terminal-host root is unavailable" + ), + }; + owner_keys.extend(records.iter().map(|(_, record)| ShutdownOwnerKey::Hosted { + terminal_id: record.terminal_id.clone(), + incarnation: record.incarnation.clone(), + })); + if owner_keys.len() > capacity { + anyhow::bail!( + "load terminal hosts for server shutdown: shutdown owner capacity \ + {capacity} cannot retain {} unique owners", + owner_keys.len() + ); + } + records + }; + + let (owners, closed_count, tree_changed, closed_public_ids) = { + let mutation = WorkspaceMutation::local("cmux-tui-shutdown"); + let operation = "server.stop"; + let fingerprint = serde_json::json!({"operation":operation}); + let mut registry = self.workspace_registry.lock().unwrap(); + let terminal_snapshot = registry.terminal_snapshot()?; + let terminals = terminal_snapshot + .terminals + .into_iter() + .map(|terminal| (terminal.terminal_id, terminal.incarnation)) + .collect::>(); + let closed_public_ids = Self::terminal_public_ids_for_hosted(®istry, &terminals)?; + let preparation = registry.prepare_resource_effect( + &mutation.id, + operation, + &fingerprint, + &serde_json::json!({"scope":"session"}), + None, + None, + )?; + anyhow::ensure!( + matches!(preparation, ResourceEffectPreparation::Execute { .. }), + "server stop could not reserve its durable close transaction" + ); + registry.mark_resource_effect_executing(&mutation.id, operation, &fingerprint)?; + + let result = (|| -> anyhow::Result<_> { + let mut state = self.state.lock().unwrap(); + let retained_count = self.shutdown_owners.len(); + let closed_count = state.surfaces.len() + retained_count; + let tree_changed = !state.surfaces.is_empty() + || !state.panes.is_empty() + || state.workspaces.iter().any(|workspace| !workspace.screens.is_empty()); + let owners = unique_surface_runtimes(&state); + let mut projected = state.clone(); + projected.surfaces.clear(); + projected.terminal_catalog.clear(); + projected.terminal_catalog_by_runtime.clear(); + for workspace in &mut projected.workspaces { + workspace.screens.clear(); + workspace.active_screen = 0; + } + if !projected.panes.is_empty() { + projected.pane_revision = projected.pane_revision.saturating_add(1); + } + projected.panes.clear(); + projected.split_screens.clear(); + projected.rebuild_resource_indexes(); + let projection = self.resource_effect_projection_locked( + ®istry, + &mut projected, + serde_json::json!({"stopped":true}), + )?; + let close = registry.commit_resource_close_patch( + &mutation.id, + operation, + &fingerprint, + &projection.patch, + &projection.result, + &projection.changes, + &terminals, + None, + )?; + projected.resource_revision = close.resource.revision; + for surface in &owners { + let _ = self.shutdown_owners.stage_surface(surface); + } + *state = projected; + Ok((owners, closed_count, tree_changed, close.terminal_batch)) + })(); + let (owners, closed_count, tree_changed, terminal_batch) = match result { + Ok(result) => result, + Err(error) => { + let _ = registry.mark_resource_effect_indeterminate(&mutation.id); + return Err(error); + } + }; + + if terminal_batch.closed != 0 { + self.emit_terminal_registry_changed(®istry, terminal_batch.revision); + } + (owners, closed_count, tree_changed, closed_public_ids) + }; + self.notify_terminal_exit_waiters(closed_public_ids); + self.publish_resource_event(); + self.pending_workspace_surfaces.lock().unwrap().clear(); + self.agent_records.lock().unwrap().clear(); + self.sidebar_plugin.lock().unwrap().surface = None; + *self.client_sizing.lock().unwrap() = ClientSizingState::default(); + if tree_changed { + self.emit(MuxEvent::TreeChanged); + } + + drop(owners); + #[cfg(unix)] + for (record_path, record) in terminal_host_records { + let owner = SurfaceShutdownOwner::hosted(record, record_path); + let key = owner + .hosted_identity() + .map(|(terminal_id, incarnation)| ShutdownOwnerKey::Hosted { + terminal_id: terminal_id.to_string(), + incarnation: incarnation.to_string(), + }) + .expect("hosted owner has a terminal identity"); + let _ = self.shutdown_owners.stage(key, owner); + } + self.terminate_staged_shutdown_owners_until(deadline); + let browser_runtime_failed = self.shutdown_browser_runtimes_until(deadline); + + let retained = self.shutdown_owners.snapshot(); + let mut failed_surfaces = retained + .iter() + .filter_map(|(key, owner)| match key { + ShutdownOwnerKey::Surface(_) => { + Some(format!("key={key:?} phase={}", owner.retained_shutdown_phase())) + } + ShutdownOwnerKey::Hosted { .. } => None, + }) + .collect::>(); + failed_surfaces.sort(); + let mut failed_hosts = retained + .iter() + .filter_map(|(key, owner)| match key { + ShutdownOwnerKey::Hosted { .. } => { + Some(format!("key={key:?} phase={}", owner.retained_shutdown_phase())) + } + ShutdownOwnerKey::Surface(_) => None, + }) + .collect::>(); + failed_hosts.sort(); + + let mut failures = Vec::new(); + if !failed_surfaces.is_empty() { + failures.push(format!( + "could not terminate {} surface process(es): {}", + failed_surfaces.len(), + failed_surfaces.join(", ") + )); + } + if browser_runtime_failed { + failures.push("could not terminate the owned browser before the deadline".to_string()); + } + if !failed_hosts.is_empty() { + failures.push(format!( + "could not terminate {} terminal host(s): {}", + failed_hosts.len(), + failed_hosts.join(", ") + )); + } + if !failures.is_empty() { + let diagnostic = failures.join("; "); + anyhow::bail!("{diagnostic}"); + } + + cleanup_lifecycle.complete(); + Ok(closed_count) + } + + #[cfg(unix)] + fn load_terminal_host_records_for_shutdown( + &self, + root: std::path::PathBuf, + capacity: usize, + deadline: Instant, + ) -> anyhow::Result { + #[cfg(test)] + { + let loader = self.terminal_host_record_loader.lock().unwrap().clone(); + self.terminal_host_record_scan.scan_until( + root, + capacity, + deadline, + move |root, capacity, deadline| { + if let Some(loader) = loader { + loader(root, capacity, deadline) + } else { + crate::terminal_host_runtime::load_terminal_host_records_strict( + &root, capacity, deadline, + ) + } + }, + ) + } + #[cfg(not(test))] + { + self.terminal_host_record_scan.scan_until( + root, + capacity, + deadline, + |root, capacity, deadline| { + crate::terminal_host_runtime::load_terminal_host_records_strict( + &root, capacity, deadline, + ) + }, + ) } } + fn lock_shutdown_coordinator_until( + &self, + deadline: Instant, + ) -> anyhow::Result> { + self.shutdown_coordinator.lock_until(deadline) + } + /// Publish that every owner needed by canonical server lifecycle commands /// is installed. Ordinary control clients may connect before this point. pub fn mark_server_lifecycle_ready(&self) { @@ -8593,1838 +10881,2856 @@ impl Mux { if expected_identity.pid != actual_identity.pid { anyhow::bail!("daemon pid changed; identify again"); } - if expected_identity.generation != actual_identity.generation { - anyhow::bail!("daemon generation changed; identify again"); + if expected_identity.generation != actual_identity.generation { + anyhow::bail!("daemon generation changed; identify again"); + } + } + self.control_clients.begin_daemon_handoff(requesting_client, request.force)?; + let deadline = Instant::now() + crate::server::SERVER_SHUTDOWN_TIMEOUT; + let preflight: anyhow::Result<()> = (|| { + #[cfg(unix)] + crate::process_session::require_stable_process_signaling_until(deadline) + .context("preflight process control for daemon handoff")?; + let _coordinator = self.lock_shutdown_coordinator_until(deadline)?; + Ok(()) + })(); + if preflight.is_err() { + self.cancel_daemon_handoff(requesting_client); + } + preflight?; + Ok(actual_identity) + } + + pub(crate) fn commit_daemon_handoff_after_ack( + &self, + requesting_client: u64, + acknowledge: impl FnOnce() -> std::io::Result<()>, + ) -> anyhow::Result<()> { + self.control_clients.commit_daemon_handoff_after_ack(requesting_client, acknowledge) + } + + pub fn cancel_daemon_handoff(&self, requesting_client: u64) { + self.control_clients.cancel_daemon_handoff(requesting_client); + } + + /// Ask the owning frontend loop to leave through the normal daemon + /// shutdown path. Durable terminal hosts are disconnected by `shutdown` + /// and remain available for the replacement daemon to adopt. + pub fn request_daemon_shutdown(&self) { + self.shutdown_cleanup_lifecycle.schedule(); + self.browser_runtime.stop_with_fence(|| self.shutting_down.store(true, Ordering::Release)); + self.surface_creations.stop(); + self.async_surface_creations.stop(); + self.daemon_shutdown_requested.store(true, Ordering::Release); + #[cfg(unix)] + self.request_terminal_adoption_stop(); + self.request_shutdown(); + } + + pub fn daemon_shutdown_requested(&self) -> bool { + self.daemon_shutdown_requested.load(Ordering::Acquire) + } + + pub(crate) fn is_shutting_down(&self) -> bool { + self.shutting_down.load(Ordering::Acquire) + } + + /// Update options used for future surface/browser launches. + pub fn update_surface_options(&self, update: impl FnOnce(&mut SurfaceOptions)) { + let mut options = self.surface_options.lock().unwrap(); + update(&mut options); + options.browser_session_name = self.session.clone(); + } + + pub fn configure_sidebar_plugin(&self, options: Option) { + let old_surface = { + let mut runtime = self.sidebar_plugin.lock().unwrap(); + if runtime.options == options { + return; + } + runtime.options = options; + runtime.last_error = None; + runtime.failures = 0; + runtime.retry_at = None; + runtime.surface.take() + }; + let retired = old_surface.and_then(|id| { + let mut state = self.state.lock().unwrap(); + take_surface_for_retirement(self, &mut state, id) + }); + if let Some(surface) = retired { + self.retire_surface_runtime(surface.clone()); + self.emit(MuxEvent::SurfaceExited(surface.id)); + } + } + + pub fn ensure_sidebar_plugin( + self: &Arc, + cols: u16, + rows: u16, + relaunch: bool, + ) -> SidebarPluginStatus { + let _creation = match self.begin_surface_creation() { + Ok(creation) => creation, + Err(error) => { + return SidebarPluginStatus { + surface: None, + error: Some(error.to_string()), + retry_after: None, + }; + } + }; + let now = Instant::now(); + let size = (cols.max(1), rows.max(1)); + let spawn_options = { + let mut runtime = self.sidebar_plugin.lock().unwrap(); + let Some(options) = runtime.options.clone() else { + return SidebarPluginStatus { surface: None, error: None, retry_after: None }; + }; + runtime.last_size = Some(size); + if let Some(surface_id) = runtime.surface { + if let Some(surface) = self.surface(surface_id).filter(|surface| !surface.is_dead()) + { + drop(runtime); + let _ = self.resize_surface(surface_id, size.0, size.1); + drop(surface); + return SidebarPluginStatus { + surface: Some(surface_id), + error: None, + retry_after: None, + }; + } + runtime.surface = None; + } + if let Some(error) = runtime.last_error.clone() { + let retry_after = runtime.retry_at.and_then(|retry_at| { + (retry_at > now).then_some(retry_at.saturating_duration_since(now)) + }); + if !relaunch || retry_after.is_some() { + return SidebarPluginStatus { surface: None, error: Some(error), retry_after }; + } + } + options + }; + match self.spawn_sidebar_plugin_surface(&spawn_options, size) { + Ok(surface) => { + let surface_id = surface.id; + { + let mut runtime = self.sidebar_plugin.lock().unwrap(); + runtime.surface = Some(surface_id); + runtime.last_error = None; + runtime.failures = 0; + runtime.retry_at = None; + } + self.reap_if_dead(&surface); + SidebarPluginStatus { surface: Some(surface_id), error: None, retry_after: None } + } + Err(err) => { + let mut runtime = self.sidebar_plugin.lock().unwrap(); + runtime.surface = None; + runtime.failures = runtime.failures.saturating_add(1); + let delay = sidebar_retry_delay(runtime.failures); + let message = format!("sidebar plugin failed to start: {err}"); + runtime.last_error = Some(message.clone()); + runtime.retry_at = Some(now + delay); + SidebarPluginStatus { + surface: None, + error: Some(message), + retry_after: Some(delay), + } } } - self.control_clients.begin_daemon_handoff(requesting_client, request.force)?; - Ok(actual_identity) - } - - pub(crate) fn commit_daemon_handoff_after_ack( - &self, - requesting_client: u64, - acknowledge: impl FnOnce() -> std::io::Result<()>, - ) -> anyhow::Result<()> { - self.control_clients.commit_daemon_handoff_after_ack(requesting_client, acknowledge) - } - - pub fn cancel_daemon_handoff(&self, requesting_client: u64) { - self.control_clients.cancel_daemon_handoff(requesting_client); } - /// Ask the owning frontend loop to leave through the normal daemon - /// shutdown path. Durable terminal hosts are disconnected by `shutdown` - /// and remain available for the replacement daemon to adopt. - pub fn request_daemon_shutdown(&self) { - self.shutting_down.store(true, Ordering::Release); + #[cfg(test)] + pub(crate) fn sidebar_plugin_status(&self) -> SidebarPluginStatus { + let runtime = self.sidebar_plugin.lock().unwrap(); + let now = Instant::now(); + let surface = runtime + .surface + .filter(|surface| self.surface(*surface).is_some_and(|surface| !surface.is_dead())); + SidebarPluginStatus { + surface, + error: runtime.last_error.clone(), + retry_after: runtime + .retry_at + .and_then(|retry_at| (retry_at > now).then(|| retry_at.duration_since(now))), + } } - pub fn daemon_shutdown_requested(&self) -> bool { - self.shutting_down.load(Ordering::Acquire) + pub(crate) fn sidebar_plugin_surface(&self) -> Option> { + let surface = self.sidebar_plugin.lock().unwrap().surface?; + self.surface(surface) } - /// Update options used for future surface/browser launches. - pub fn update_surface_options(&self, update: impl FnOnce(&mut SurfaceOptions)) { - let mut options = self.surface_options.lock().unwrap(); - update(&mut options); - options.browser_session_name = self.session.clone(); + pub(crate) fn sidebar_plugin_resource_status( + &self, + ) -> (SidebarPluginStatus, Option<(u16, u16)>, bool) { + let runtime = self.sidebar_plugin.lock().unwrap(); + let now = Instant::now(); + let surface = runtime + .surface + .filter(|surface| self.surface(*surface).is_some_and(|surface| !surface.is_dead())); + ( + SidebarPluginStatus { + surface, + error: runtime.last_error.clone(), + retry_after: runtime + .retry_at + .and_then(|retry_at| (retry_at > now).then(|| retry_at.duration_since(now))), + }, + runtime.last_size, + runtime.options.is_some(), + ) } - pub fn configure_sidebar_plugin(&self, options: Option) { + pub(crate) fn reload_sidebar_plugin( + self: &Arc, + cols: u16, + rows: u16, + ) -> SidebarPluginStatus { let old_surface = { let mut runtime = self.sidebar_plugin.lock().unwrap(); - if runtime.options == options { - return; - } - runtime.options = options; + runtime.last_size = Some((cols.max(1), rows.max(1))); runtime.last_error = None; runtime.failures = 0; runtime.retry_at = None; runtime.surface.take() }; - if let Some(surface) = - old_surface.and_then(|id| self.state.lock().unwrap().surfaces.remove(&id)) - { - surface.kill(); + let removed = old_surface.and_then(|id| { + let mut state = self.state.lock().unwrap(); + take_surface_for_retirement(self, &mut state, id) + }); + if let Some(surface) = removed { + self.purge_surface_side_tables(surface.id); + self.retire_surface_runtime(surface.clone()); self.emit(MuxEvent::SurfaceExited(surface.id)); } + self.ensure_sidebar_plugin(cols, rows, true) + } + + pub(crate) fn resource_resize_sidebar_selected( + &self, + selectors: crate::ResourceSelectors, + sidebar_id: &SidebarViewPublicId, + cols: u16, + rows: u16, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + let cols = cols.max(1); + let rows = rows.max(1); + let fingerprint = serde_json::json!({ + "operation":"sidebar_view.resize", + "selectors":selectors, + "sidebar_view":sidebar_id, + "cols":cols, + "rows":rows, + }); + if let Some(replay) = self.workspace_registry.lock().unwrap().replay_resource_patch( + mutation, + "sidebar_view.resize", + &fingerprint, + )? { + return Ok(replay); + } + let raw = selectors.sidebar_view.as_deref().ok_or_else(|| { + anyhow::Error::new(ResourceError::selector_invalid( + "sidebar_view", + "", + "missing required sidebar_view selector", + )) + })?; + match Selector::parse(raw).map_err(anyhow::Error::new)? { + Selector::Current => {} + Selector::Id(id) if id == sidebar_id.as_str() => {} + Selector::Name(name) if matches!(name.as_str(), "sidebar" | "default") => {} + Selector::Id(_) | Selector::Name(_) => { + return Err(anyhow::Error::new(ResourceError::not_found("sidebar_view", raw))); + } + } + let mut runtime = self.sidebar_plugin.lock().unwrap(); + anyhow::ensure!(runtime.options.is_some(), "sidebar view is not configured"); + let surface_id = runtime.surface.context("sidebar view is not running")?; + let surface = self + .state + .lock() + .unwrap() + .surfaces + .get(&surface_id) + .cloned() + .context("sidebar view surface disappeared")?; + anyhow::ensure!(!surface.is_dead(), "sidebar view is not running"); + let mut session_selectors = selectors.clone(); + session_selectors.sidebar_view = None; + let sidebar_id = sidebar_id.clone(); + let commit = self.commit_resource_mutation_plan( + mutation, + "sidebar_view.resize", + &fingerprint, + None, + expected_revision, + move |state, registry| { + self.resolve_resource_path_in_state( + state, + registry, + crate::ResourceTarget::Session, + &session_selectors, + ) + .map_err(anyhow::Error::new)?; + let value = serde_json::json!({ + "id":sidebar_id, + "session_id":registry.session_id(), + "cols":cols, + "rows":rows, + "running":true, + }); + let deltas = serde_json::json!([{ + "kind":"upsert", + "sequence":0, + "resource":"sidebar_view", + "id":sidebar_id, + "value":value, + }]); + Ok(ResourceMutationPlan::new( + ResourcePatch { changes: Vec::new() }, + value, + deltas, + move |_state| { + let _ = surface.resize(cols, rows); + }, + ) + .with_metrics(ResourceMutationMetrics { + touched_resources: 1, + order_entries: 0, + terminal_queries: 0, + changed_rows: 0, + })) + }, + )?; + if !commit.replayed { + runtime.last_size = Some((cols, rows)); + } + Ok(commit) + } + + pub fn set_cell_pixel_size(self: &Arc, width_px: u16, height_px: u16) -> CellPixelUpdate { + self.set_cell_pixel_size_reporting(width_px, height_px, Arc::new(|_, _, _| {})) + } + + pub fn cell_pixel_size(&self) -> (u16, u16) { + *self.cell_pixels.lock().unwrap() + } + + pub(crate) fn claim_render_attachment(&self) -> Option { + self.active_render_attachments + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < RENDER_ATTACHMENT_LIMIT).then_some(active + 1) + }) + .ok()?; + Some(RenderAttachmentPermit { active: self.active_render_attachments.clone() }) } - pub fn ensure_sidebar_plugin( + pub(crate) fn reserve_kitty_image_surface( self: &Arc, - cols: u16, - rows: u16, - relaunch: bool, - ) -> SidebarPluginStatus { - let now = Instant::now(); - let size = (cols.max(1), rows.max(1)); - let spawn_options = { - let mut runtime = self.sidebar_plugin.lock().unwrap(); - let Some(options) = runtime.options.clone() else { - return SidebarPluginStatus { surface: None, error: None, retry_after: None }; - }; - runtime.last_size = Some(size); - if let Some(surface_id) = runtime.surface { - if let Some(surface) = self.surface(surface_id).filter(|surface| !surface.is_dead()) - { - drop(runtime); - let _ = self.resize_surface(surface_id, size.0, size.1); - drop(surface); - return SidebarPluginStatus { - surface: Some(surface_id), - error: None, - retry_after: None, - }; - } - runtime.surface = None; + surface: SurfaceId, + ) -> anyhow::Result { + { + let mut budget = self.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); + anyhow::ensure!( + !budget.entries.contains_key(&surface), + "Kitty image budget already reserved for surface {surface}" + ); + let owner_count = Self::kitty_image_budget_owner_count(&budget); + let owns_quota = + budget.blocked_surfaces.is_empty() && owner_count < KITTY_IMAGE_BUDGET_OWNER_LIMIT; + if owns_quota { + budget.capacity = kitty_image_budget_capacity(owner_count + 1, budget.capacity); } - if let Some(error) = runtime.last_error.clone() { - let retry_after = runtime.retry_at.and_then(|retry_at| { - (retry_at > now).then_some(retry_at.saturating_duration_since(now)) - }); - if !relaunch || retry_after.is_some() { - return SidebarPluginStatus { surface: None, error: Some(error), retry_after }; + budget.entries.insert( + surface, + KittyImageBudgetEntry { + surface: None, + applied: KittyGraphicsLimits::disabled(), + owns_quota, + removing: false, + }, + ); + } + self.start_kitty_image_budget_worker(); + let deadline = Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; + let initial_limits = + loop { + let mut budget = self.kitty_image_budget.lock().unwrap(); + if !budget.blocked_surfaces.is_empty() { + let entry = budget.entries.get_mut(&surface).ok_or_else(|| { + anyhow::anyhow!("Kitty image budget reservation disappeared") + })?; + entry.owns_quota = false; + entry.applied = KittyGraphicsLimits::disabled(); + Self::rebalance_kitty_image_budget_owners(&mut budget); + break KittyGraphicsLimits::disabled(); } - } - options - }; - match self.spawn_sidebar_plugin_surface(&spawn_options, size) { - Ok(surface) => { - let surface_id = surface.id; + let owns_quota = budget + .entries + .get(&surface) + .ok_or_else(|| anyhow::anyhow!("Kitty image budget reservation disappeared"))? + .owns_quota; + if !owns_quota { + break KittyGraphicsLimits::disabled(); + } + let target = kitty_image_limits_for_capacity(budget.capacity); + if !budget.expansion_in_flight + && kitty_image_limits_enabled(target) + && budget.entries.iter().all(|(&id, entry)| { + id == surface || kitty_image_limits_within(entry.applied, target) + }) { - let mut runtime = self.sidebar_plugin.lock().unwrap(); - runtime.surface = Some(surface_id); - runtime.last_error = None; - runtime.failures = 0; - runtime.retry_at = None; + let entry = budget.entries.get_mut(&surface).ok_or_else(|| { + anyhow::anyhow!("Kitty image budget reservation disappeared") + })?; + entry.applied = target; + break target; } - self.reap_if_dead(&surface); - SidebarPluginStatus { surface: Some(surface_id), error: None, retry_after: None } - } - Err(err) => { - let mut runtime = self.sidebar_plugin.lock().unwrap(); - runtime.surface = None; - runtime.failures = runtime.failures.saturating_add(1); - let delay = sidebar_retry_delay(runtime.failures); - let message = format!("sidebar plugin failed to start: {err}"); - runtime.last_error = Some(message.clone()); - runtime.retry_at = Some(now + delay); - SidebarPluginStatus { - surface: None, - error: Some(message), - retry_after: Some(delay), + if self.shutting_down.load(Ordering::Acquire) { + drop(budget); + self.cancel_kitty_image_surface_reservation(surface); + anyhow::bail!("multiplexer shut down while reserving Kitty image quota"); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + drop(budget); + self.cancel_kitty_image_surface_reservation(surface); + anyhow::bail!( + "timed out waiting for existing terminals to release Kitty image quota" + ); } + let (next, _) = + self.kitty_image_budget_changed.wait_timeout(budget, remaining).unwrap(); + drop(next); + }; + Ok(KittyImageBudgetReservation { + mux: Arc::downgrade(self), + surface, + initial_limits, + committed: false, + }) + } + + fn commit_kitty_image_surface( + self: &Arc, + id: SurfaceId, + surface: &Arc, + applied: KittyGraphicsLimits, + ) -> anyhow::Result<()> { + { + let mut budget = self.kitty_image_budget.lock().unwrap(); + let entry = budget + .entries + .get_mut(&id) + .ok_or_else(|| anyhow::anyhow!("Kitty image budget reservation disappeared"))?; + anyhow::ensure!( + entry.surface.is_none() && !entry.removing, + "Kitty image budget reservation is no longer pending" + ); + entry.surface = Some(Arc::downgrade(surface)); + entry.applied = applied; + Self::rebalance_kitty_image_budget_owners(&mut budget); + } + self.start_kitty_image_budget_worker(); + Ok(()) + } + + fn cancel_kitty_image_surface_reservation(self: &Arc, id: SurfaceId) { + { + let mut budget = self.kitty_image_budget.lock().unwrap(); + if budget.entries.get(&id).is_some_and(|entry| entry.surface.is_none()) { + budget.entries.remove(&id); + Self::rebalance_kitty_image_budget_owners(&mut budget); } } + self.kitty_image_budget_changed.notify_all(); + self.start_kitty_image_budget_worker(); } - #[cfg(test)] - pub(crate) fn sidebar_plugin_status(&self) -> SidebarPluginStatus { - let runtime = self.sidebar_plugin.lock().unwrap(); - let now = Instant::now(); - let surface = runtime - .surface - .filter(|surface| self.surface(*surface).is_some_and(|surface| !surface.is_dead())); - SidebarPluginStatus { - surface, - error: runtime.last_error.clone(), - retry_after: runtime - .retry_at - .and_then(|retry_at| (retry_at > now).then(|| retry_at.duration_since(now))), + pub(crate) fn unregister_kitty_image_surface( + self: &Arc, + surface: &Surface, + ) -> anyhow::Result<()> { + let runtime_id = surface.terminal_runtime_id().unwrap_or(surface.id); + { + let mut budget = self.kitty_image_budget.lock().unwrap(); + let removed_current_surface = budget + .entries + .get(&runtime_id) + .and_then(|entry| entry.surface.as_ref()) + .and_then(Weak::upgrade) + .is_some_and(|registered| std::ptr::eq(registered.as_ref(), surface)); + if removed_current_surface { + budget.entries.remove(&runtime_id); + budget.blocked_surfaces.remove(&runtime_id); + Self::rebalance_kitty_image_budget_owners(&mut budget); + } } + self.kitty_image_budget_changed.notify_all(); + self.start_kitty_image_budget_worker(); + Ok(()) } - pub(crate) fn sidebar_plugin_surface(&self) -> Option> { - let surface = self.sidebar_plugin.lock().unwrap().surface?; - self.surface(surface) + pub(crate) fn resource_terminal_host_identity( + &self, + surface: &Surface, + ) -> Option { + surface.terminal_host_identity().or_else(|| { + let runtime = surface.terminal_runtime_id()?; + self.reserved_in_process_terminals.lock().unwrap().get(&runtime).cloned() + }) } - pub(crate) fn sidebar_plugin_resource_status( + fn catalog_terminal_by_host( &self, - ) -> (SidebarPluginStatus, Option<(u16, u16)>, bool) { - let runtime = self.sidebar_plugin.lock().unwrap(); - let now = Instant::now(); - let surface = runtime - .surface - .filter(|surface| self.surface(*surface).is_some_and(|surface| !surface.is_dead())); - ( - SidebarPluginStatus { - surface, - error: runtime.last_error.clone(), - retry_after: runtime - .retry_at - .and_then(|retry_at| (retry_at > now).then(|| retry_at.duration_since(now))), - }, - runtime.last_size, - runtime.options.is_some(), + state: &State, + terminal_id: &str, + ) -> anyhow::Result>> { + unique_terminal_match( + terminal_id, + state.terminal_catalog.values().filter_map(|surface| { + self.resource_terminal_host_identity(surface) + .map(|identity| (surface.clone(), identity)) + }), ) + .map(|matched| matched.map(|(surface, _)| surface)) + } + + pub(crate) fn kitty_image_limits_for_reconnect( + &self, + surface: &Arc, + ) -> anyhow::Result { + let mut budget = self.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); + let target = kitty_image_limits_for_capacity(budget.capacity); + let entry = budget + .entries + .get(&surface.id) + .ok_or_else(|| anyhow::anyhow!("Kitty image budget entry disappeared on reconnect"))?; + anyhow::ensure!( + entry + .surface + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|registered| Arc::ptr_eq(®istered, surface)), + "Kitty image budget entry changed ownership on reconnect" + ); + Ok(if entry.removing || !entry.owns_quota { + KittyGraphicsLimits::disabled() + } else { + target + }) } - pub(crate) fn reload_sidebar_plugin( + fn reconcile_reconnected_kitty_image_surface( self: &Arc, - cols: u16, - rows: u16, - ) -> SidebarPluginStatus { - let old_surface = { - let mut runtime = self.sidebar_plugin.lock().unwrap(); - runtime.last_size = Some((cols.max(1), rows.max(1))); - runtime.last_error = None; - runtime.failures = 0; - runtime.retry_at = None; - runtime.surface.take() + surface: &Arc, + applied: KittyGraphicsLimits, + ) -> bool { + let reconciled = { + let mut budget = self.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); + let target = kitty_image_limits_for_capacity(budget.capacity); + let Some(entry) = budget.entries.get_mut(&surface.id) else { return false }; + let owns_entry = entry + .surface + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|registered| Arc::ptr_eq(®istered, surface)); + let desired = if entry.removing || !entry.owns_quota { + KittyGraphicsLimits::disabled() + } else { + target + }; + if !owns_entry || !kitty_image_limits_within(applied, desired) { + false + } else { + entry.applied = applied; + budget.blocked_surfaces.remove(&surface.id); + true + } }; - if let Some(surface) = - old_surface.and_then(|id| self.state.lock().unwrap().surfaces.remove(&id)) - { - self.purge_surface_side_tables(surface.id); - surface.kill(); - self.emit(MuxEvent::SurfaceExited(surface.id)); + if reconciled { + self.kitty_image_budget_changed.notify_all(); + self.start_kitty_image_budget_worker(); } - self.ensure_sidebar_plugin(cols, rows, true) + reconciled } - pub(crate) fn resource_resize_sidebar_selected( - &self, - selectors: crate::ResourceSelectors, - sidebar_id: &SidebarViewPublicId, - cols: u16, - rows: u16, - expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - let cols = cols.max(1); - let rows = rows.max(1); - let fingerprint = serde_json::json!({ - "operation":"sidebar_view.resize", - "selectors":selectors, - "sidebar_view":sidebar_id, - "cols":cols, - "rows":rows, + fn prune_dead_kitty_image_surfaces(budget: &mut KittyImageBudgetState) { + budget.entries.retain(|_, entry| { + entry.surface.as_ref().is_none_or(|surface| surface.strong_count() > 0) }); - if let Some(replay) = self.workspace_registry.lock().unwrap().replay_resource_patch( - mutation, - "sidebar_view.resize", - &fingerprint, - )? { - return Ok(replay); - } - let raw = selectors.sidebar_view.as_deref().ok_or_else(|| { - anyhow::Error::new(ResourceError::selector_invalid( - "sidebar_view", - "", - "missing required sidebar_view selector", - )) - })?; - match Selector::parse(raw).map_err(anyhow::Error::new)? { - Selector::Current => {} - Selector::Id(id) if id == sidebar_id.as_str() => {} - Selector::Name(name) if matches!(name.as_str(), "sidebar" | "default") => {} - Selector::Id(_) | Selector::Name(_) => { - return Err(anyhow::Error::new(ResourceError::not_found("sidebar_view", raw))); - } - } - let mut runtime = self.sidebar_plugin.lock().unwrap(); - anyhow::ensure!(runtime.options.is_some(), "sidebar view is not configured"); - let surface_id = runtime.surface.context("sidebar view is not running")?; - let surface = self - .state - .lock() - .unwrap() - .surfaces - .get(&surface_id) - .cloned() - .context("sidebar view surface disappeared")?; - anyhow::ensure!(!surface.is_dead(), "sidebar view is not running"); - let mut session_selectors = selectors.clone(); - session_selectors.sidebar_view = None; - let sidebar_id = sidebar_id.clone(); - let commit = self.commit_resource_mutation_plan( - mutation, - "sidebar_view.resize", - &fingerprint, - None, - expected_revision, - move |state, registry| { - self.resolve_resource_path_in_state( - state, - registry, - crate::ResourceTarget::Session, - &session_selectors, - ) - .map_err(anyhow::Error::new)?; - let value = serde_json::json!({ - "id":sidebar_id, - "session_id":registry.session_id(), - "cols":cols, - "rows":rows, - "running":true, - }); - let deltas = serde_json::json!([{ - "kind":"upsert", - "sequence":0, - "resource":"sidebar_view", - "id":sidebar_id, - "value":value, - }]); - Ok(ResourceMutationPlan::new( - ResourcePatch { changes: Vec::new() }, - value, - deltas, - move |_state| { - let _ = surface.resize(cols, rows); - }, - ) - .with_metrics(ResourceMutationMetrics { - touched_resources: 1, - order_entries: 0, - terminal_queries: 0, - changed_rows: 0, - })) - }, - )?; - if !commit.replayed { - runtime.last_size = Some((cols, rows)); - } - Ok(commit) - } - - pub fn set_cell_pixel_size(self: &Arc, width_px: u16, height_px: u16) -> CellPixelUpdate { - self.set_cell_pixel_size_reporting(width_px, height_px, Arc::new(|_, _, _| {})) + let live_ids = budget.entries.keys().copied().collect::>(); + budget.blocked_surfaces.retain(|id| live_ids.contains(id)); + Self::rebalance_kitty_image_budget_owners(budget); } - pub fn cell_pixel_size(&self) -> (u16, u16) { - *self.cell_pixels.lock().unwrap() + fn kitty_image_budget_owner_count(budget: &KittyImageBudgetState) -> usize { + budget.entries.values().filter(|entry| entry.owns_quota).count() } - pub(crate) fn claim_render_attachment(&self) -> Option { - self.active_render_attachments - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { - (active < RENDER_ATTACHMENT_LIMIT).then_some(active + 1) - }) - .ok()?; - Some(RenderAttachmentPermit { active: self.active_render_attachments.clone() }) + fn rebalance_kitty_image_budget_owners(budget: &mut KittyImageBudgetState) { + let owner_count = Self::kitty_image_budget_owner_count(budget); + debug_assert!(owner_count <= KITTY_IMAGE_BUDGET_OWNER_LIMIT); + let available = KITTY_IMAGE_BUDGET_OWNER_LIMIT.saturating_sub(owner_count); + if budget.blocked_surfaces.is_empty() && available > 0 && owner_count < budget.entries.len() + { + let mut candidates = budget + .entries + .iter() + .filter_map(|(&id, entry)| { + (!entry.owns_quota + && !entry.removing + && entry.surface.as_ref().is_some_and(|surface| surface.strong_count() > 0)) + .then_some(id) + }) + .collect::>(); + candidates.sort_unstable(); + for id in candidates.into_iter().take(available) { + if let Some(entry) = budget.entries.get_mut(&id) { + entry.owns_quota = true; + } + } + } + budget.capacity = kitty_image_budget_capacity( + Self::kitty_image_budget_owner_count(budget), + budget.capacity, + ); } - pub(crate) fn reserve_kitty_image_surface( - self: &Arc, - surface: SurfaceId, - ) -> anyhow::Result { - { + fn start_kitty_image_budget_worker(self: &Arc) { + let should_start = { let mut budget = self.kitty_image_budget.lock().unwrap(); Self::prune_dead_kitty_image_surfaces(&mut budget); - anyhow::ensure!( - !budget.entries.contains_key(&surface), - "Kitty image budget already reserved for surface {surface}" - ); - let owner_count = Self::kitty_image_budget_owner_count(&budget); - let owns_quota = - budget.blocked_surfaces.is_empty() && owner_count < KITTY_IMAGE_BUDGET_OWNER_LIMIT; - if owns_quota { - budget.capacity = kitty_image_budget_capacity(owner_count + 1, budget.capacity); + let target = kitty_image_limits_for_capacity(budget.capacity); + let has_work = budget.entries.values().any(|entry| { + entry.surface.as_ref().is_some_and(|surface| surface.strong_count() > 0) + && entry.applied + != if entry.removing || !entry.owns_quota { + KittyGraphicsLimits::disabled() + } else { + target + } + }); + if budget.worker_running || !budget.blocked_surfaces.is_empty() || !has_work { + false + } else { + budget.worker_running = true; + true } - budget.entries.insert( - surface, - KittyImageBudgetEntry { - surface: None, - applied: KittyGraphicsLimits::disabled(), - owns_quota, - removing: false, + }; + if !should_start { + return; + } + let mux = Arc::downgrade(self); + if let Err(error) = std::thread::Builder::new() + .name("kitty-image-budget".into()) + .spawn(move || Self::run_kitty_image_budget_worker(mux)) + { + self.kitty_image_budget.lock().unwrap().worker_running = false; + self.kitty_image_budget_changed.notify_all(); + self.emit(MuxEvent::GraphicsStatus( + GraphicsStatus::KittyImageBudgetWorkerStartFailed { + error: Arc::::from(error.to_string()), }, - ); + )); } - self.start_kitty_image_budget_worker(); - let deadline = Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; - let initial_limits = - loop { - let mut budget = self.kitty_image_budget.lock().unwrap(); - if !budget.blocked_surfaces.is_empty() { - let entry = budget.entries.get_mut(&surface).ok_or_else(|| { - anyhow::anyhow!("Kitty image budget reservation disappeared") - })?; - entry.owns_quota = false; - entry.applied = KittyGraphicsLimits::disabled(); - Self::rebalance_kitty_image_budget_owners(&mut budget); - break KittyGraphicsLimits::disabled(); - } - let owns_quota = budget - .entries - .get(&surface) - .ok_or_else(|| anyhow::anyhow!("Kitty image budget reservation disappeared"))? - .owns_quota; - if !owns_quota { - break KittyGraphicsLimits::disabled(); + } + + fn run_kitty_image_budget_worker(mux: Weak) { + let mut failure_streak = 0_u32; + let mut pending_operations = Vec::::new(); + loop { + let Some(mux) = mux.upgrade() else { return }; + if mux.shutting_down.load(Ordering::Acquire) { + let mut budget = mux.kitty_image_budget.lock().unwrap(); + budget.expansion_in_flight = false; + budget.worker_running = false; + drop(budget); + mux.kitty_image_budget_changed.notify_all(); + return; + } + + let mut failures = Vec::new(); + let mut failed_operations = HashSet::new(); + let mut failed_surface_ids = HashSet::new(); + let mut retained_pending = Vec::new(); + let mut pending_completed = false; + { + let mut budget = mux.kitty_image_budget.lock().unwrap(); + for pending in pending_operations.drain(..) { + let Some(result) = pending.result.try_take() else { + retained_pending.push(pending); + continue; + }; + pending_completed = true; + match result { + Ok(()) => { + if let Some(entry) = budget.entries.get_mut(&pending.surface_id) + && entry + .surface + .as_ref() + .and_then(Weak::upgrade) + .zip(pending.surface.upgrade()) + .is_some_and(|(registered, completed)| { + Arc::ptr_eq(®istered, &completed) + }) + { + entry.applied = pending.limits; + } + } + Err(error) => { + failed_operations.insert(pending.surface_id); + failed_surface_ids.insert(pending.surface_id); + failures.push(format!("surface {}: {error}", pending.surface_id)); + } + } } + budget.expansion_in_flight = + retained_pending.iter().any(|pending| pending.expanding); + } + pending_operations = retained_pending; + if pending_completed { + mux.kitty_image_budget_changed.notify_all(); + } + + let pending_ids = + pending_operations.iter().map(|pending| pending.surface_id).collect::>(); + let (tasks, deferred_expansion) = { + let mut budget = mux.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); let target = kitty_image_limits_for_capacity(budget.capacity); - if !budget.expansion_in_flight - && kitty_image_limits_enabled(target) - && budget.entries.iter().all(|(&id, entry)| { - id == surface || kitty_image_limits_within(entry.applied, target) - }) - { - let entry = budget.entries.get_mut(&surface).ok_or_else(|| { - anyhow::anyhow!("Kitty image budget reservation disappeared") - })?; - entry.applied = target; - break target; + let mut tasks = Vec::new(); + for (&id, entry) in &budget.entries { + if pending_ids.contains(&id) || failed_operations.contains(&id) { + continue; + } + let Some(surface) = entry.surface.as_ref().and_then(Weak::upgrade) else { + continue; + }; + let desired = if entry.removing || !entry.owns_quota { + KittyGraphicsLimits::disabled() + } else { + target + }; + if entry.applied != desired + && (entry.removing + || !entry.owns_quota + || kitty_image_limits_exceed(entry.applied, desired)) + { + tasks.push((id, surface, desired, false)); + } } - if self.shutting_down.load(Ordering::Acquire) { - drop(budget); - self.cancel_kitty_image_surface_reservation(surface); - anyhow::bail!("multiplexer shut down while reserving Kitty image quota"); + if tasks.is_empty() { + budget.entries.retain(|id, entry| { + pending_ids.contains(id) + || !(entry.removing + && (entry.surface.is_none() + || entry.applied == KittyGraphicsLimits::disabled())) + }); + let previous_capacity = budget.capacity; + Self::rebalance_kitty_image_budget_owners(&mut budget); + if budget.capacity != previous_capacity { + continue; + } + let target = kitty_image_limits_for_capacity(budget.capacity); + for (&id, entry) in &budget.entries { + if pending_ids.contains(&id) || failed_operations.contains(&id) { + continue; + } + let Some(surface) = entry.surface.as_ref().and_then(Weak::upgrade) else { + continue; + }; + if entry.owns_quota && !entry.removing && entry.applied != target { + tasks.push(( + id, + surface, + target, + kitty_image_limits_exceed(target, entry.applied), + )); + } + } } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { + budget.expansion_in_flight = + pending_operations.iter().any(|pending| pending.expanding) + || tasks.iter().any(|task| task.3); + if tasks.is_empty() && pending_operations.is_empty() && failed_operations.is_empty() + { + budget.expansion_in_flight = false; + budget.worker_running = false; drop(budget); - self.cancel_kitty_image_surface_reservation(surface); - anyhow::bail!( - "timed out waiting for existing terminals to release Kitty image quota" - ); + mux.kitty_image_budget_changed.notify_all(); + return; + } + // The deadline pool deliberately bounds admitted operations. + // Submit at most one pool-width wave, then recompute desired + // limits before the next wave so large topology bursts cannot + // turn ordinary queueing into false saturation failures. + tasks.sort_unstable_by_key(|task| task.0); + let deferred_expansion = + tasks.iter().skip(CELL_PIXEL_FANOUT_MAX_WORKERS).any(|task| task.3); + tasks.truncate(CELL_PIXEL_FANOUT_MAX_WORKERS); + (tasks, deferred_expansion) + }; + + if !tasks.is_empty() { + let deadline = + Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; + let operation_mux = Arc::downgrade(&mux); + let results = bounded_deadline_map( + &mux.deadline_fanout_pool, + &tasks, + deadline, + move |(_, surface, limits, _), deadline| { + let Some(mux) = operation_mux.upgrade() else { + anyhow::bail!("multiplexer shut down before Kitty quota update"); + }; + mux.apply_kitty_image_limits(surface, *limits, deadline) + }, + ); + let mut budget = mux.kitty_image_budget.lock().unwrap(); + let mut retry_expansion = false; + for ((id, surface, limits, expanding), result) in tasks.iter().zip(results) { + match result { + DeadlineMapResult::Complete(Ok(())) => { + if let Some(entry) = budget.entries.get_mut(id) + && entry + .surface + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|registered| Arc::ptr_eq(®istered, surface)) + { + entry.applied = *limits; + } + } + DeadlineMapResult::Complete(Err(error)) => { + retry_expansion |= *expanding; + failed_surface_ids.insert(*id); + failures.push(format!("surface {id}: {error}")); + } + DeadlineMapResult::Pending(result) => { + pending_operations.push(PendingKittyImageBudgetOperation { + surface_id: *id, + surface: Arc::downgrade(surface), + limits: *limits, + expanding: *expanding, + result, + }); + } + DeadlineMapResult::Unscheduled => { + retry_expansion |= *expanding; + failed_surface_ids.insert(*id); + failures.push(format!( + "surface {id}: update was rejected because the deadline worker \ + pool is saturated" + )); + } + } + } + budget.expansion_in_flight = deferred_expansion + || retry_expansion + || pending_operations.iter().any(|pending| pending.expanding); + } + for pending in &pending_operations { + if failed_surface_ids.insert(pending.surface_id) { + failures.push(format!( + "surface {}: update did not complete before its deadline", + pending.surface_id + )); + } + } + mux.kitty_image_budget_changed.notify_all(); + if failures.is_empty() { + failure_streak = 0; + continue; + } + + failure_streak = failure_streak.saturating_add(1); + let retry_exhausted = failure_streak >= KITTY_IMAGE_BUDGET_RETRY_MAX_ATTEMPTS; + if failure_streak == 1 || failure_streak.is_power_of_two() || retry_exhausted { + let omitted = failures.len().saturating_sub(8); + let mut summary = failures.into_iter().take(8).collect::>().join("; "); + if omitted > 0 { + summary.push_str(&format!("; {omitted} more")); } - let (next, _) = - self.kitty_image_budget_changed.wait_timeout(budget, remaining).unwrap(); - drop(next); - }; - Ok(KittyImageBudgetReservation { - mux: Arc::downgrade(self), - surface, - initial_limits, - committed: false, - }) + mux.emit(MuxEvent::GraphicsStatus(GraphicsStatus::KittyImageBudgetUpdateFailed { + retry_exhausted, + summary: Arc::::from(summary), + })); + } + if retry_exhausted { + let mut budget = mux.kitty_image_budget.lock().unwrap(); + let blocked = failed_surface_ids + .into_iter() + .filter(|id| budget.entries.contains_key(id)) + .collect::>(); + budget.blocked_surfaces.extend(blocked); + budget.expansion_in_flight = false; + budget.worker_running = false; + drop(budget); + mux.kitty_image_budget_changed.notify_all(); + return; + } + let multiplier = + 1_u32.checked_shl(failure_streak.saturating_sub(1).min(16)).unwrap_or(u32::MAX); + let delay = KITTY_IMAGE_BUDGET_RETRY_INITIAL + .saturating_mul(multiplier) + .min(KITTY_IMAGE_BUDGET_RETRY_MAX); + drop(mux); + std::thread::sleep(delay); + } } - fn commit_kitty_image_surface( - self: &Arc, - id: SurfaceId, + fn apply_kitty_image_limits( + &self, surface: &Arc, - applied: KittyGraphicsLimits, + limits: KittyGraphicsLimits, + deadline: Instant, ) -> anyhow::Result<()> { - { - let mut budget = self.kitty_image_budget.lock().unwrap(); - let entry = budget - .entries - .get_mut(&id) - .ok_or_else(|| anyhow::anyhow!("Kitty image budget reservation disappeared"))?; - anyhow::ensure!( - entry.surface.is_none() && !entry.removing, - "Kitty image budget reservation is no longer pending" - ); - entry.surface = Some(Arc::downgrade(surface)); - entry.applied = applied; - Self::rebalance_kitty_image_budget_owners(&mut budget); + #[cfg(test)] + if let Some(operation) = self.kitty_image_budget_operation.lock().unwrap().clone() { + return operation(surface, limits, deadline); } - self.start_kitty_image_budget_worker(); - Ok(()) + surface.set_kitty_graphics_limits_until(limits, deadline) } - fn cancel_kitty_image_surface_reservation(self: &Arc, id: SurfaceId) { - { - let mut budget = self.kitty_image_budget.lock().unwrap(); - if budget.entries.get(&id).is_some_and(|entry| entry.surface.is_none()) { - budget.entries.remove(&id); - Self::rebalance_kitty_image_budget_owners(&mut budget); + #[cfg(test)] + fn wait_for_kitty_image_budget_idle_for_test(&self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let mut budget = self.kitty_image_budget.lock().unwrap(); + loop { + let target = kitty_image_limits_for_capacity(budget.capacity); + let idle = !budget.worker_running + && budget.entries.values().all(|entry| { + entry.surface.is_some() + && !entry.removing + && entry.applied + == if entry.owns_quota { + target + } else { + KittyGraphicsLimits::disabled() + } + }); + if idle { + return true; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + let (next, timed_out) = + self.kitty_image_budget_changed.wait_timeout(budget, remaining).unwrap(); + budget = next; + if timed_out.timed_out() && Instant::now() >= deadline { + return false; } } - self.kitty_image_budget_changed.notify_all(); - self.start_kitty_image_budget_worker(); } - pub(crate) fn unregister_kitty_image_surface( - self: &Arc, - surface: &Surface, - ) -> anyhow::Result<()> { - let runtime_id = surface.terminal_runtime_id().unwrap_or(surface.id); + pub(crate) fn cell_pixel_creation_size(&self) -> (u16, u16) { + if let Some(target) = self + .pending_cell_pixels + .lock() + .unwrap() + .as_ref() + .filter(|pending| pending.use_for_creation) + .map(|pending| pending.target) { - let mut budget = self.kitty_image_budget.lock().unwrap(); - let removed_current_surface = budget - .entries - .get(&runtime_id) - .and_then(|entry| entry.surface.as_ref()) - .and_then(Weak::upgrade) - .is_some_and(|registered| std::ptr::eq(registered.as_ref(), surface)); - if removed_current_surface { - budget.entries.remove(&runtime_id); - budget.blocked_surfaces.remove(&runtime_id); - Self::rebalance_kitty_image_budget_owners(&mut budget); + return target; + } + self.cell_pixel_size() + } + + fn reconcile_surface_cell_pixels_for_publish<'a>( + &'a self, + surface: &Arc, + ) -> anyhow::Result> { + loop { + let lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let target = self.cell_pixel_creation_size(); + if surface.cell_pixel_size() == target { + return Ok(lifecycle); } + drop(lifecycle); + validate_cell_pixel_convergence( + surface, + target, + surface.set_cell_pixel_size_reporting_until( + target.0, + target.1, + Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT, + Box::new(|_| {}), + ), + )?; } - self.kitty_image_budget_changed.notify_all(); - self.start_kitty_image_budget_worker(); - Ok(()) } - pub(crate) fn resource_terminal_host_identity( - &self, - surface: &Surface, - ) -> Option { - surface.terminal_host_identity().or_else(|| { - let runtime = surface.terminal_runtime_id()?; - self.reserved_in_process_terminals.lock().unwrap().get(&runtime).cloned() - }) + pub(crate) fn reconcile_deferred_cell_pixel_ack(&self, surface: SurfaceId, target: (u16, u16)) { + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + self.reconcile_cell_pixel_ack_locked(surface, target); } - fn catalog_terminal_by_host( + pub(crate) fn submit_deferred_cell_pixel_ack( &self, - state: &State, - terminal_id: &str, - ) -> anyhow::Result>> { - unique_terminal_match( - terminal_id, - state.terminal_catalog.values().filter_map(|surface| { - self.resource_terminal_host_identity(surface) - .map(|identity| (surface.clone(), identity)) - }), - ) - .map(|matched| matched.map(|(surface, _)| surface)) + task: impl FnOnce() + Send + 'static, + ) -> bool { + self.deadline_fanout_pool.submit(Box::new(task)) } - pub(crate) fn kitty_image_limits_for_reconnect( - &self, - surface: &Arc, - ) -> anyhow::Result { - let mut budget = self.kitty_image_budget.lock().unwrap(); - Self::prune_dead_kitty_image_surfaces(&mut budget); - let target = kitty_image_limits_for_capacity(budget.capacity); - let entry = budget - .entries - .get(&surface.id) - .ok_or_else(|| anyhow::anyhow!("Kitty image budget entry disappeared on reconnect"))?; - anyhow::ensure!( - entry - .surface - .as_ref() - .and_then(Weak::upgrade) - .is_some_and(|registered| Arc::ptr_eq(®istered, surface)), - "Kitty image budget entry changed ownership on reconnect" - ); - Ok(if entry.removing || !entry.owns_quota { - KittyGraphicsLimits::disabled() - } else { - target - }) + fn reconcile_cell_pixel_ack_locked(&self, surface: SurfaceId, target: (u16, u16)) { + let mut pending = self.pending_cell_pixels.lock().unwrap(); + let Some(update) = pending.as_mut().filter(|update| update.target == target) else { + return; + }; + update.failures.remove(&surface); + if !update.failures.is_empty() { + return; + } + *self.cell_pixels.lock().unwrap() = target; + *pending = None; } - fn reconcile_reconnected_kitty_image_surface( - self: &Arc, - surface: &Arc, - applied: KittyGraphicsLimits, - ) -> bool { - let reconciled = { - let mut budget = self.kitty_image_budget.lock().unwrap(); - Self::prune_dead_kitty_image_surfaces(&mut budget); - let target = kitty_image_limits_for_capacity(budget.capacity); - let Some(entry) = budget.entries.get_mut(&surface.id) else { return false }; - let owns_entry = entry - .surface - .as_ref() - .and_then(Weak::upgrade) - .is_some_and(|registered| Arc::ptr_eq(®istered, surface)); - let desired = if entry.removing || !entry.owns_quota { - KittyGraphicsLimits::disabled() - } else { - target - }; - if !owns_entry || !kitty_image_limits_within(applied, desired) { - false - } else { - entry.applied = applied; - budget.blocked_surfaces.remove(&surface.id); - true - } + fn reconcile_cell_pixel_completion_locked( + &self, + surface: SurfaceId, + generation: u64, + target: (u16, u16), + ) { + let mut pending = self.pending_cell_pixels.lock().unwrap(); + let Some(update) = pending + .as_mut() + .filter(|update| update.generation == generation && update.target == target) + else { + return; }; - if reconciled { - self.kitty_image_budget_changed.notify_all(); - self.start_kitty_image_budget_worker(); + update.failures.remove(&surface); + if !update.failures.is_empty() { + return; } - reconciled - } - - fn prune_dead_kitty_image_surfaces(budget: &mut KittyImageBudgetState) { - budget.entries.retain(|_, entry| { - entry.surface.as_ref().is_none_or(|surface| surface.strong_count() > 0) - }); - let live_ids = budget.entries.keys().copied().collect::>(); - budget.blocked_surfaces.retain(|id| live_ids.contains(id)); - Self::rebalance_kitty_image_budget_owners(budget); + *self.cell_pixels.lock().unwrap() = target; + *pending = None; } - fn kitty_image_budget_owner_count(budget: &KittyImageBudgetState) -> usize { - budget.entries.values().filter(|entry| entry.owns_quota).count() + fn record_cell_pixel_completion( + self: &Arc, + completion: &Arc, + surface: SurfaceId, + ) { + completion.completed.lock().unwrap().insert(surface); + if completion.publishing.load(Ordering::Acquire) { + return; + } + let _lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + if completion.completed.lock().unwrap().remove(&surface) { + self.reconcile_cell_pixel_completion_locked( + surface, + completion.generation, + completion.target, + ); + } } - fn rebalance_kitty_image_budget_owners(budget: &mut KittyImageBudgetState) { - let owner_count = Self::kitty_image_budget_owner_count(budget); - debug_assert!(owner_count <= KITTY_IMAGE_BUDGET_OWNER_LIMIT); - let available = KITTY_IMAGE_BUDGET_OWNER_LIMIT.saturating_sub(owner_count); - if budget.blocked_surfaces.is_empty() && available > 0 && owner_count < budget.entries.len() + fn enqueue_cell_pixel_retries( + self: &Arc, + task: CellPixelRetryTask, + ) -> std::io::Result<()> { + let mut retries = self.cell_pixel_retries.lock().unwrap(); + if retries.pending.as_ref().is_some_and(|pending| pending.generation > task.generation) { + return Ok(()); + } + retries.pending = Some(task); + if retries.worker_running { + return Ok(()); + } + retries.worker_running = true; + let mux = Arc::downgrade(self); + match std::thread::Builder::new() + .name("cell-pixel-retry".to_string()) + .spawn(move || Self::run_cell_pixel_retry_worker(mux)) { - let mut candidates = budget - .entries - .iter() - .filter_map(|(&id, entry)| { - (!entry.owns_quota - && !entry.removing - && entry.surface.as_ref().is_some_and(|surface| surface.strong_count() > 0)) - .then_some(id) - }) - .collect::>(); - candidates.sort_unstable(); - for id in candidates.into_iter().take(available) { - if let Some(entry) = budget.entries.get_mut(&id) { - entry.owns_quota = true; - } + Ok(_) => Ok(()), + Err(error) => { + retries.worker_running = false; + retries.pending = None; + Err(error) } } - budget.capacity = kitty_image_budget_capacity( - Self::kitty_image_budget_owner_count(budget), - budget.capacity, - ); } - fn start_kitty_image_budget_worker(self: &Arc) { - let should_start = { - let mut budget = self.kitty_image_budget.lock().unwrap(); - Self::prune_dead_kitty_image_surfaces(&mut budget); - let target = kitty_image_limits_for_capacity(budget.capacity); - let has_work = budget.entries.values().any(|entry| { - entry.surface.as_ref().is_some_and(|surface| surface.strong_count() > 0) - && entry.applied - != if entry.removing || !entry.owns_quota { - KittyGraphicsLimits::disabled() - } else { - target - } - }); - if budget.worker_running || !budget.blocked_surfaces.is_empty() || !has_work { - false - } else { - budget.worker_running = true; - true + fn run_cell_pixel_retry_worker(mux: Weak) { + loop { + let task = { + let Some(mux) = mux.upgrade() else { return }; + let mut retries = mux.cell_pixel_retries.lock().unwrap(); + if mux.shutting_down.load(Ordering::Acquire) { + retries.pending = None; + retries.worker_running = false; + return; + } + match retries.pending.take() { + Some(task) => task, + None => { + retries.worker_running = false; + return; + } + } + }; + if let Some(mut task) = Self::run_cell_pixel_retry_task(&mux, task) { + task.attempts = task.attempts.saturating_add(1); + if task.attempts >= CELL_PIXEL_RETRY_MAX_ATTEMPTS { + if let Some(mux) = mux.upgrade() { + mux.finish_cell_pixel_retries(&task); + } + continue; + } + std::thread::sleep(cell_pixel_retry_delay(task.attempts)); + let Some(mux) = mux.upgrade() else { return }; + let mut retries = mux.cell_pixel_retries.lock().unwrap(); + if retries.pending.is_none() { + retries.pending = Some(task); + } } - }; - if !should_start { - return; - } - let mux = Arc::downgrade(self); - if let Err(error) = std::thread::Builder::new() - .name("kitty-image-budget".into()) - .spawn(move || Self::run_kitty_image_budget_worker(mux)) - { - self.kitty_image_budget.lock().unwrap().worker_running = false; - self.kitty_image_budget_changed.notify_all(); - self.emit(MuxEvent::GraphicsStatus( - GraphicsStatus::KittyImageBudgetWorkerStartFailed { - error: Arc::::from(error.to_string()), - }, - )); } } - fn run_kitty_image_budget_worker(mux: Weak) { - let mut failure_streak = 0_u32; - let mut pending_operations = Vec::::new(); - loop { - let Some(mux) = mux.upgrade() else { return }; - if mux.shutting_down.load(Ordering::Acquire) { - let mut budget = mux.kitty_image_budget.lock().unwrap(); - budget.expansion_in_flight = false; - budget.worker_running = false; - drop(budget); - mux.kitty_image_budget_changed.notify_all(); + fn finish_cell_pixel_retries(&self, task: &CellPixelRetryTask) { + let remaining = { + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let mut pending = self.pending_cell_pixels.lock().unwrap(); + let Some(pending) = pending.as_mut().filter(|pending| { + pending.generation == task.generation && pending.target == task.target + }) else { return; - } + }; + pending.use_for_creation = false; + pending.failures.len() + }; + self.emit(MuxEvent::GraphicsStatus(GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts: task.attempts, + remaining, + cell_pixels: task.target, + })); + } - let mut failures = Vec::new(); - let mut failed_operations = HashSet::new(); - let mut failed_surface_ids = HashSet::new(); - let mut retained_pending = Vec::new(); - let mut pending_completed = false; - { - let mut budget = mux.kitty_image_budget.lock().unwrap(); - for pending in pending_operations.drain(..) { - let Some(result) = pending.result.try_take() else { - retained_pending.push(pending); - continue; - }; - pending_completed = true; - match result { - Ok(()) => { - if let Some(entry) = budget.entries.get_mut(&pending.surface_id) - && entry - .surface - .as_ref() - .and_then(Weak::upgrade) - .zip(pending.surface.upgrade()) - .is_some_and(|(registered, completed)| { - Arc::ptr_eq(®istered, &completed) - }) - { - entry.applied = pending.limits; - } - } - Err(error) => { - failed_operations.insert(pending.surface_id); - failed_surface_ids.insert(pending.surface_id); - failures.push(format!("surface {}: {error}", pending.surface_id)); - } + fn run_cell_pixel_retry_task( + mux: &Weak, + task: CellPixelRetryTask, + ) -> Option { + let mut retry_candidates = task.surfaces; + let mut pending_operations = Vec::new(); + for pending in task.pending { + let Some(result) = pending.result.try_take() else { + pending_operations.push(pending); + continue; + }; + let mux = mux.upgrade()?; + let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); + if !mux.pending_cell_pixels.lock().unwrap().as_ref().is_some_and(|update| { + update.generation == task.generation && update.target == task.target + }) { + return None; + } + let (surface_id, _, result, deferred) = result; + match result { + Ok(_) => mux.reconcile_cell_pixel_completion_locked( + surface_id, + task.generation, + task.target, + ), + Err(_) if deferred => {} + Err(_) => retry_candidates.push(pending.surface), + } + } + let mut unique = HashSet::new(); + retry_candidates + .retain(|surface| surface.upgrade().is_some_and(|surface| unique.insert(surface.id))); + let mut remaining = Vec::new(); + for retry_wave in retry_candidates.chunks(CELL_PIXEL_FANOUT_MAX_WORKERS) { + let mux = mux.upgrade()?; + let active = { + let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); + let pending = mux.pending_cell_pixels.lock().unwrap(); + let pending = pending.as_ref().filter(|pending| { + pending.generation == task.generation + && pending.target == task.target + && !pending.failures.is_empty() + })?; + retry_wave + .iter() + .filter_map(Weak::upgrade) + .filter(|surface| pending.failures.contains(&surface.id)) + .collect::>() + }; + let deadline = Instant::now() + task.timeout; + let report = task.report.clone(); + #[cfg(test)] + let operation_hook = task.operation_hook.clone(); + let target = task.target; + let completion = task.completion.clone(); + let completion_mux = Arc::downgrade(&mux); + let results = bounded_deadline_map( + &mux.deadline_fanout_pool, + &active, + deadline, + move |surface, deadline| { + let result = apply_cell_pixel_size_until( + surface, + target, + deadline, + &report, + #[cfg(test)] + operation_hook.as_ref(), + ); + let completed_at = Instant::now(); + if completed_at <= deadline + && result.2.is_ok() + && let Some(mux) = completion_mux.upgrade() + { + mux.record_cell_pixel_completion(&completion, surface.id); } - } - budget.expansion_in_flight = - retained_pending.iter().any(|pending| pending.expanding); - } - pending_operations = retained_pending; - if pending_completed { - mux.kitty_image_budget_changed.notify_all(); + result + }, + ); + let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); + if !mux.pending_cell_pixels.lock().unwrap().as_ref().is_some_and(|pending| { + pending.generation == task.generation && pending.target == task.target + }) { + return None; } - - let pending_ids = - pending_operations.iter().map(|pending| pending.surface_id).collect::>(); - let (tasks, deferred_expansion) = { - let mut budget = mux.kitty_image_budget.lock().unwrap(); - Self::prune_dead_kitty_image_surfaces(&mut budget); - let target = kitty_image_limits_for_capacity(budget.capacity); - let mut tasks = Vec::new(); - for (&id, entry) in &budget.entries { - if pending_ids.contains(&id) || failed_operations.contains(&id) { - continue; + for (surface, result) in active.iter().zip(results) { + let (surface_id, result, deferred) = match result { + DeadlineMapResult::Complete((surface_id, _, result, deferred)) => { + (surface_id, Some(result), deferred) } - let Some(surface) = entry.surface.as_ref().and_then(Weak::upgrade) else { + DeadlineMapResult::Pending(result) => { + pending_operations.push(PendingCellPixelOperation { + surface: Arc::downgrade(surface), + result, + }); continue; - }; - let desired = if entry.removing || !entry.owns_quota { - KittyGraphicsLimits::disabled() - } else { - target - }; - if entry.applied != desired - && (entry.removing - || !entry.owns_quota - || kitty_image_limits_exceed(entry.applied, desired)) - { - tasks.push((id, surface, desired, false)); } - } - if tasks.is_empty() { - budget.entries.retain(|id, entry| { - pending_ids.contains(id) - || !(entry.removing - && (entry.surface.is_none() - || entry.applied == KittyGraphicsLimits::disabled())) - }); - let previous_capacity = budget.capacity; - Self::rebalance_kitty_image_budget_owners(&mut budget); - if budget.capacity != previous_capacity { + DeadlineMapResult::Unscheduled => { + remaining.push(Arc::downgrade(surface)); continue; } - let target = kitty_image_limits_for_capacity(budget.capacity); - for (&id, entry) in &budget.entries { - if pending_ids.contains(&id) || failed_operations.contains(&id) { - continue; - } - let Some(surface) = entry.surface.as_ref().and_then(Weak::upgrade) else { - continue; - }; - if entry.owns_quota && !entry.removing && entry.applied != target { - tasks.push(( - id, - surface, - target, - kitty_image_limits_exceed(target, entry.applied), - )); + }; + match result.expect("complete deadline result has an operation result") { + Ok(_) => mux.reconcile_cell_pixel_completion_locked( + surface_id, + task.generation, + task.target, + ), + Err(_) if deferred => {} + Err(error) + if error + .downcast_ref::< + crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed, + >() + .is_some() => + { + remaining.push(Arc::downgrade(surface)); + } + Err(_) => { + if let Some(pending) = mux + .pending_cell_pixels + .lock() + .unwrap() + .as_mut() + .filter(|pending| { + pending.generation == task.generation + && pending.target == task.target + }) + { + pending.use_for_creation = false; } } } - budget.expansion_in_flight = - pending_operations.iter().any(|pending| pending.expanding) - || tasks.iter().any(|task| task.3); - if tasks.is_empty() && pending_operations.is_empty() && failed_operations.is_empty() + } + } + let pending_ids = pending_operations + .iter() + .filter_map(|pending| pending.surface.upgrade()) + .map(|surface| surface.id) + .collect::>(); + let mut unique = HashSet::new(); + remaining.retain(|surface| { + surface.upgrade().is_some_and(|surface| { + !pending_ids.contains(&surface.id) && unique.insert(surface.id) + }) + }); + (!remaining.is_empty() || !pending_operations.is_empty()).then_some(CellPixelRetryTask { + surfaces: remaining, + pending: pending_operations, + attempts: task.attempts, + generation: task.generation, + target: task.target, + completion: task.completion, + report: task.report, + timeout: task.timeout, + #[cfg(test)] + operation_hook: task.operation_hook, + }) + } + + pub fn set_cell_pixel_size_reporting( + self: &Arc, + width_px: u16, + height_px: u16, + report: SurfaceResizeReporter, + ) -> CellPixelUpdate { + let cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let generation = self.next_cell_pixel_generation.fetch_add(1, Ordering::Relaxed); + let next = (width_px.max(1), height_px.max(1)); + let completion = Arc::new(CellPixelCompletionTracker { + generation, + target: next, + publishing: AtomicBool::new(true), + completed: Mutex::new(HashSet::new()), + }); + let mut surfaces = unique_surface_runtimes(&self.state.lock().unwrap()); + surfaces.sort_unstable_by_key(|surface| surface.id); + #[cfg(test)] + let timeout = self + .cell_pixel_fanout_timeout + .lock() + .unwrap() + .unwrap_or(crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT); + #[cfg(not(test))] + let timeout = crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; + let deadline = Instant::now() + timeout; + #[cfg(test)] + let operation_hook = self.cell_pixel_operation.lock().unwrap().clone(); + #[cfg(test)] + let fanout_operation_hook = operation_hook.clone(); + let operation_report = report.clone(); + let operation_completion = completion.clone(); + let operation_mux = Arc::downgrade(self); + let results = bounded_deadline_map( + &self.deadline_fanout_pool, + &surfaces, + deadline, + move |surface, deadline| { + let result = apply_cell_pixel_size_until( + surface, + next, + deadline, + &operation_report, + #[cfg(test)] + fanout_operation_hook.as_ref(), + ); + let completed_at = Instant::now(); + if completed_at <= deadline + && result.2.is_ok() + && let Some(mux) = operation_mux.upgrade() { - budget.expansion_in_flight = false; - budget.worker_running = false; - drop(budget); - mux.kitty_image_budget_changed.notify_all(); - return; + mux.record_cell_pixel_completion(&operation_completion, surface.id); + } + result + }, + ); + let mut update = CellPixelUpdate::default(); + let mut retry_surfaces = Vec::new(); + let mut pending_operations = Vec::new(); + for (surface, result) in surfaces.iter().zip(results) { + let (id, size, result, deferred) = match result { + DeadlineMapResult::Complete(result) => result, + DeadlineMapResult::Pending(result) => { + pending_operations.push(PendingCellPixelOperation { + surface: Arc::downgrade(surface), + result, + }); + update.failures.push(CellPixelUpdateFailure { + surface: surface.id, + error: "cell pixel update is still running after the shared deadline" + .to_string(), + deferred: true, + }); + continue; + } + DeadlineMapResult::Unscheduled => { + retry_surfaces.push(Arc::downgrade(surface)); + update.failures.push(CellPixelUpdateFailure { + surface: surface.id, + error: "cell pixel update was deferred because the deadline worker pool \ + is saturated" + .to_string(), + deferred: true, + }); + continue; } - // The deadline pool deliberately bounds admitted operations. - // Submit at most one pool-width wave, then recompute desired - // limits before the next wave so large topology bursts cannot - // turn ordinary queueing into false saturation failures. - tasks.sort_unstable_by_key(|task| task.0); - let deferred_expansion = - tasks.iter().skip(CELL_PIXEL_FANOUT_MAX_WORKERS).any(|task| task.3); - tasks.truncate(CELL_PIXEL_FANOUT_MAX_WORKERS); - (tasks, deferred_expansion) }; - - if !tasks.is_empty() { - let deadline = - Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; - let operation_mux = Arc::downgrade(&mux); - let results = bounded_deadline_map( - &mux.deadline_fanout_pool, - &tasks, - deadline, - move |(_, surface, limits, _), deadline| { - let Some(mux) = operation_mux.upgrade() else { - anyhow::bail!("multiplexer shut down before Kitty quota update"); - }; - mux.apply_kitty_image_limits(surface, *limits, deadline) - }, - ); - let mut budget = mux.kitty_image_budget.lock().unwrap(); - let mut retry_expansion = false; - for ((id, surface, limits, expanding), result) in tasks.iter().zip(results) { - match result { - DeadlineMapResult::Complete(Ok(())) => { - if let Some(entry) = budget.entries.get_mut(id) - && entry - .surface - .as_ref() - .and_then(Weak::upgrade) - .is_some_and(|registered| Arc::ptr_eq(®istered, surface)) - { - entry.applied = *limits; - } - } - DeadlineMapResult::Complete(Err(error)) => { - retry_expansion |= *expanding; - failed_surface_ids.insert(*id); - failures.push(format!("surface {id}: {error}")); - } - DeadlineMapResult::Pending(result) => { - pending_operations.push(PendingKittyImageBudgetOperation { - surface_id: *id, - surface: Arc::downgrade(surface), - limits: *limits, - expanding: *expanding, - result, - }); - } - DeadlineMapResult::Unscheduled => { - retry_expansion |= *expanding; - failed_surface_ids.insert(*id); - failures.push(format!( - "surface {id}: update was rejected because the deadline worker \ - pool is saturated" - )); - } + match result { + Ok(Some(reservation_id)) => update.resizes.push((id, size, reservation_id)), + Ok(None) => {} + Err(error) => { + let retry = error + .downcast_ref::< + crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed, + >() + .is_some(); + if retry { + retry_surfaces.push(Arc::downgrade(surface)); } - } - budget.expansion_in_flight = deferred_expansion - || retry_expansion - || pending_operations.iter().any(|pending| pending.expanding); - } - for pending in &pending_operations { - if failed_surface_ids.insert(pending.surface_id) { - failures.push(format!( - "surface {}: update did not complete before its deadline", - pending.surface_id - )); + update.failures.push(CellPixelUpdateFailure { + surface: id, + error: error.to_string(), + deferred: deferred || retry, + }); } } - mux.kitty_image_budget_changed.notify_all(); - if failures.is_empty() { - failure_streak = 0; - continue; - } - - failure_streak = failure_streak.saturating_add(1); - let retry_exhausted = failure_streak >= KITTY_IMAGE_BUDGET_RETRY_MAX_ATTEMPTS; - if failure_streak == 1 || failure_streak.is_power_of_two() || retry_exhausted { - let omitted = failures.len().saturating_sub(8); - let mut summary = failures.into_iter().take(8).collect::>().join("; "); - if omitted > 0 { - summary.push_str(&format!("; {omitted} more")); + } + let completed = std::mem::take(&mut *completion.completed.lock().unwrap()); + if !completed.is_empty() { + update.failures.retain(|failure| !completed.contains(&failure.surface)); + retry_surfaces.retain(|surface| { + surface.upgrade().is_some_and(|surface| !completed.contains(&surface.id)) + }); + pending_operations.retain(|pending| { + pending.surface.upgrade().is_some_and(|surface| !completed.contains(&surface.id)) + }); + } + #[cfg(test)] + if let Some(hook) = self.cell_pixel_before_publish.lock().unwrap().clone() { + hook(self.cell_pixel_size()); + } + // Keep the published value at the last fully converged metric. New + // surfaces use the pending target, and late hosted acknowledgements + // remove their exact failure before publishing it globally. + if update.failures.is_empty() { + *self.cell_pixels.lock().unwrap() = next; + *self.pending_cell_pixels.lock().unwrap() = None; + } else { + *self.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { + generation, + target: next, + failures: update.failures.iter().map(|failure| failure.surface).collect(), + use_for_creation: update.failures.iter().all(|failure| failure.deferred), + }); + } + completion.publishing.store(false, Ordering::Release); + let raced_completions = std::mem::take(&mut *completion.completed.lock().unwrap()); + for surface in &raced_completions { + self.reconcile_cell_pixel_completion_locked(*surface, generation, next); + } + if !raced_completions.is_empty() { + update.failures.retain(|failure| !raced_completions.contains(&failure.surface)); + retry_surfaces.retain(|surface| { + surface.upgrade().is_some_and(|surface| !raced_completions.contains(&surface.id)) + }); + pending_operations.retain(|pending| { + pending + .surface + .upgrade() + .is_some_and(|surface| !raced_completions.contains(&surface.id)) + }); + } + let retry_ids = retry_surfaces + .iter() + .filter_map(Weak::upgrade) + .map(|surface| surface.id) + .chain( + pending_operations + .iter() + .filter_map(|pending| pending.surface.upgrade()) + .map(|surface| surface.id), + ) + .collect::>(); + let retry_spawn = if retry_surfaces.is_empty() && pending_operations.is_empty() { + Ok(()) + } else { + self.enqueue_cell_pixel_retries(CellPixelRetryTask { + surfaces: retry_surfaces, + pending: pending_operations, + attempts: 0, + generation, + target: next, + completion, + report, + timeout, + #[cfg(test)] + operation_hook, + }) + }; + if let Err(error) = retry_spawn { + for failure in &mut update.failures { + if retry_ids.contains(&failure.surface) { + failure.deferred = false; + failure.error = format!("{}; could not schedule retry: {error}", failure.error); } - mux.emit(MuxEvent::GraphicsStatus(GraphicsStatus::KittyImageBudgetUpdateFailed { - retry_exhausted, - summary: Arc::::from(summary), - })); } - if retry_exhausted { - let mut budget = mux.kitty_image_budget.lock().unwrap(); - let blocked = failed_surface_ids - .into_iter() - .filter(|id| budget.entries.contains_key(id)) - .collect::>(); - budget.blocked_surfaces.extend(blocked); - budget.expansion_in_flight = false; - budget.worker_running = false; - drop(budget); - mux.kitty_image_budget_changed.notify_all(); - return; + if let Some(pending) = self.pending_cell_pixels.lock().unwrap().as_mut() { + pending.use_for_creation = update.failures.iter().all(|failure| failure.deferred); } - let multiplier = - 1_u32.checked_shl(failure_streak.saturating_sub(1).min(16)).unwrap_or(u32::MAX); - let delay = KITTY_IMAGE_BUDGET_RETRY_INITIAL - .saturating_mul(multiplier) - .min(KITTY_IMAGE_BUDGET_RETRY_MAX); - drop(mux); - std::thread::sleep(delay); } + drop(cell_pixel_lifecycle); + update } - fn apply_kitty_image_limits( - &self, - surface: &Arc, - limits: KittyGraphicsLimits, - deadline: Instant, - ) -> anyhow::Result<()> { - #[cfg(test)] - if let Some(operation) = self.kitty_image_budget_operation.lock().unwrap().clone() { - return operation(surface, limits, deadline); - } - surface.set_kitty_graphics_limits_until(limits, deadline) + pub fn default_colors(&self) -> DefaultColors { + *self.default_colors.lock().unwrap() } - #[cfg(test)] - fn wait_for_kitty_image_budget_idle_for_test(&self, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - let mut budget = self.kitty_image_budget.lock().unwrap(); - loop { - let target = kitty_image_limits_for_capacity(budget.capacity); - let idle = !budget.worker_running - && budget.entries.values().all(|entry| { - entry.surface.is_some() - && !entry.removing - && entry.applied - == if entry.owns_quota { - target - } else { - KittyGraphicsLimits::disabled() - } - }); - if idle { - return true; - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return false; - } - let (next, timed_out) = - self.kitty_image_budget_changed.wait_timeout(budget, remaining).unwrap(); - budget = next; - if timed_out.timed_out() && Instant::now() >= deadline { - return false; + pub fn set_default_colors(&self, colors: DefaultColors) { + let surfaces = { + let state = self.state.lock().unwrap(); + let mut current = self.default_colors.lock().unwrap(); + if *current == colors { + return; } + *current = colors; + unique_surface_runtimes(&state) + }; + for surface in surfaces { + surface.set_default_colors(colors); + self.emit_terminal_output(surface.id); } } - pub(crate) fn cell_pixel_creation_size(&self) -> (u16, u16) { - if let Some(target) = self - .pending_cell_pixels - .lock() - .unwrap() - .as_ref() - .filter(|pending| pending.use_for_creation) - .map(|pending| pending.target) - { - return target; + pub fn seed_default_colors_if_no_durable_override(&self, colors: DefaultColors) { + let surfaces = { + let state = self.state.lock().unwrap(); + let mut current = self.default_colors.lock().unwrap(); + if self.durable_terminal_defaults.load(Ordering::Acquire) || *current == colors { + return; + } + *current = colors; + unique_surface_runtimes(&state) + }; + for surface in surfaces { + surface.set_default_colors(colors); + self.emit_terminal_output(surface.id); } - self.cell_pixel_size() } - fn reconcile_surface_cell_pixels_for_publish<'a>( - &'a self, - surface: &Arc, - ) -> anyhow::Result> { - loop { - let lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); - let target = self.cell_pixel_creation_size(); - if surface.cell_pixel_size() == target { - return Ok(lifecycle); - } - drop(lifecycle); - validate_cell_pixel_convergence( - surface, - target, - surface.set_cell_pixel_size_reporting_until( - target.0, - target.1, - Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT, - Box::new(|_| {}), - ), - )?; + #[allow(clippy::too_many_arguments)] + pub(crate) fn resource_update_terminal_defaults_selected( + &self, + selectors: crate::ResourceSelectors, + fields: &Value, + colors: DefaultColors, + value: &Value, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + let mut intent_fields = fields.clone(); + if let Some(fields) = intent_fields.as_object_mut() { + fields.remove("expected_revision"); + } + let fingerprint = serde_json::json!({ + "operation":"session.terminal_defaults.update", + "selectors":selectors, + "fields":intent_fields, + }); + let mut registry = self.workspace_registry.lock().unwrap(); + if let Some(replay) = registry.replay_resource_patch( + mutation, + "session.terminal_defaults.update", + &fingerprint, + )? { + return Ok(replay); + } + let mut state = self.state.lock().unwrap(); + self.resolve_resource_path_in_state( + &state, + ®istry, + crate::ResourceTarget::Session, + &selectors, + ) + .map_err(anyhow::Error::new)?; + let surfaces = unique_surface_runtimes(&state); + let commit = registry.commit_resource_patch( + mutation, + "session.terminal_defaults.update", + &fingerprint, + None, + expected_revision, + &ResourcePatch { changes: Vec::new() }, + value, + &Value::Array(Vec::new()), + )?; + state.resource_revision = commit.revision; + self.durable_terminal_defaults.store(true, Ordering::Release); + *self.default_colors.lock().unwrap() = colors; + for surface in &surfaces { + surface.set_default_colors(colors); + } + drop(state); + drop(registry); + for surface in surfaces { + self.emit_terminal_output(surface.id); + } + if !commit.replayed { + self.publish_resource_event(); } + Ok(commit) } - pub(crate) fn reconcile_deferred_cell_pixel_ack(&self, surface: SurfaceId, target: (u16, u16)) { - let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); - self.reconcile_cell_pixel_ack_locked(surface, target); + /// Resize a surface and broadcast the final clamped size when it actually + /// changes. Browser workers broadcast after their asynchronous CDP work. + pub fn resize_surface(&self, id: SurfaceId, cols: u16, rows: u16) -> anyhow::Result { + self.resize_surface_with_reservation(id, cols, rows).map(|(accepted, _)| accepted) } - pub(crate) fn submit_deferred_cell_pixel_ack( + pub fn resize_surface_with_reservation( &self, - task: impl FnOnce() + Send + 'static, - ) -> bool { - self.deadline_fanout_pool.submit(Box::new(task)) + id: SurfaceId, + cols: u16, + rows: u16, + ) -> anyhow::Result<(bool, Option)> { + self.resize_surface_with_completion(id, cols, rows, None) } - fn reconcile_cell_pixel_ack_locked(&self, surface: SurfaceId, target: (u16, u16)) { - let mut pending = self.pending_cell_pixels.lock().unwrap(); - let Some(update) = pending.as_mut().filter(|update| update.target == target) else { - return; + fn resize_surface_with_completion( + &self, + id: SurfaceId, + cols: u16, + rows: u16, + completion: Option, + ) -> anyhow::Result<(bool, Option)> { + let Some(surface) = self.surface(id) else { + anyhow::bail!("unknown surface {id}"); }; - update.failures.remove(&surface); - if !update.failures.is_empty() { - return; + // Not recorded as a client size here: internal resizes (e.g. the + // sidebar plugin surface tracking the TUI rect every frame) also land + // in this method and must not become the default for new surfaces. + // Client interactions record explicitly at the protocol/TUI layers. + let (cols, rows) = clamp_terminal_size(cols, rows); + if surface.as_browser().is_some() { + let reservation_id = + surface.resize_reporting_completion(cols, rows, Box::new(|_| {}), completion)?; + return Ok((reservation_id.is_some(), reservation_id)); } - *self.cell_pixels.lock().unwrap() = target; - *pending = None; + let reports_asynchronously = surface.resize_reports_asynchronously(); + if !surface.resize(cols, rows)? { + if let Some(completion) = completion { + let _ = completion.send(Ok(())); + } + return Ok((false, None)); + } + if reports_asynchronously { + return Ok((true, None)); + } + if let Some(completion) = completion { + let _ = completion.send(Ok(())); + } + let (cols, rows) = surface.size(); + self.emit_terminal_resized(id, cols, rows, None); + Ok((true, None)) } - fn reconcile_cell_pixel_completion_locked( + /// Create a workspace with one screen holding one pane with one tab. + /// Returns the tab's surface. `size` is the expected content size in + /// cells, when the caller knows it (spawning at the final size avoids + /// shell redraw artifacts). + pub fn new_workspace( + self: &Arc, + name: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result> { + let _creation = self.begin_surface_creation()?; + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + let mut fields = + Map::from_iter([("initial_content".into(), Value::String("terminal".into()))]); + Self::insert_optional_string(&mut fields, "name", name); + Self::insert_cell_size(&mut fields, size); + let commit = self.commit_ordinary_topology_operation( + ResourceOperation::WorkspaceCreate, + Self::ordinary_resource_selectors(), + fields, + )?; + self.emit_resource_topology_legacy_events(ResourceOperation::WorkspaceCreate, &commit); + self.ordinary_created_surface(&commit) + } + + /// Add an ordered workspace-registry entry without creating a PTY, + /// screen, or pane. Detached GUI frontends use this when a user creates + /// an empty workspace in Chrome. + pub fn create_empty_workspace( &self, - surface: SurfaceId, - generation: u64, - target: (u16, u16), - ) { - let mut pending = self.pending_cell_pixels.lock().unwrap(); - let Some(update) = pending - .as_mut() - .filter(|update| update.generation == generation && update.target == target) - else { - return; - }; - update.failures.remove(&surface); - if !update.failures.is_empty() { - return; - } - *self.cell_pixels.lock().unwrap() = target; - *pending = None; + name: Option, + key: Option, + expected_revision: Option, + ) -> anyhow::Result { + let mutation = WorkspaceMutation::local("cmux-tui"); + self.create_empty_workspace_with_mutation(name, key, None, expected_revision, &mutation) } - fn record_cell_pixel_completion( - self: &Arc, - completion: &Arc, - surface: SurfaceId, - ) { - completion.completed.lock().unwrap().insert(surface); - if completion.publishing.load(Ordering::Acquire) { - return; - } - let _lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); - if completion.completed.lock().unwrap().remove(&surface) { - self.reconcile_cell_pixel_completion_locked( - surface, - completion.generation, - completion.target, - ); - } + pub fn create_empty_workspace_with_mutation( + &self, + name: Option, + requested_key: Option, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + self.create_empty_workspace_with_mutation_inner( + name, + requested_key, + None, + expected_generation, + expected_revision, + mutation, + true, + ) } - fn enqueue_cell_pixel_retries( - self: &Arc, - task: CellPixelRetryTask, - ) -> std::io::Result<()> { - let mut retries = self.cell_pixel_retries.lock().unwrap(); - if retries.pending.as_ref().is_some_and(|pending| pending.generation > task.generation) { - return Ok(()); - } - retries.pending = Some(task); - if retries.worker_running { - return Ok(()); + fn create_empty_workspace_for_resource_effect( + &self, + name: Option, + requested_key: Option, + public_id: WorkspacePublicId, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + self.create_empty_workspace_with_mutation_inner( + name, + requested_key, + Some(public_id), + None, + None, + mutation, + false, + ) + } + + #[allow(clippy::too_many_arguments)] + fn create_empty_workspace_with_mutation_inner( + &self, + name: Option, + requested_key: Option, + requested_public_id: Option, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + project_resource: bool, + ) -> anyhow::Result { + if let Some(name) = name.as_deref() { + Self::validate_workspace_name(name)?; } - retries.worker_running = true; - let mux = Arc::downgrade(self); - match std::thread::Builder::new() - .name("cell-pixel-retry".to_string()) - .spawn(move || Self::run_cell_pixel_retry_worker(mux)) - { - Ok(_) => Ok(()), - Err(error) => { - retries.worker_running = false; - retries.pending = None; - Err(error) + let key = match requested_key.as_ref() { + Some(key) if key.trim().is_empty() => anyhow::bail!("workspace key cannot be empty"), + Some(key) if !crate::workspace_registry::is_canonical_workspace_key(key) => { + anyhow::bail!("workspace key must be a lowercase UUID") } + Some(key) => key.clone(), + None => Self::new_workspace_key()?, + }; + Self::validate_workspace_key(&key)?; + let requested_name = name.clone(); + let ws_id = self.next_id(); + let notifications = self.surface_notifications(); + let mut registry = self.workspace_registry.lock().unwrap(); + let fingerprint = serde_json::json!({ + "op": "create-workspace", + "name": requested_name, + "requested_key": requested_key, + }); + if let Some(commit) = registry.replay(mutation, &fingerprint)? { + let workspace = commit.result["workspace"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("stored create result is missing workspace"))?; + let key = commit.result["key"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("stored create result is missing key"))? + .to_string(); + let index = commit.result["index"] + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| anyhow::anyhow!("stored create result is missing index"))?; + return Ok(WorkspacePlacement { + workspace, + key, + index, + revision: commit.revision, + replayed: true, + }); } - } - - fn run_cell_pixel_retry_worker(mux: Weak) { - loop { - let task = { - let Some(mux) = mux.upgrade() else { return }; - let mut retries = mux.cell_pixel_retries.lock().unwrap(); - if mux.shutting_down.load(Ordering::Acquire) { - retries.pending = None; - retries.worker_running = false; - return; - } - match retries.pending.take() { - Some(task) => task, - None => { - retries.worker_running = false; - return; - } - } + let workspace_public_id = + requested_public_id.map(Ok).unwrap_or_else(WorkspacePublicId::random)?; + let (placement, delta, selection_resync) = { + let mut state = self.state.lock().unwrap(); + if state.workspaces.len() >= WORKSPACE_REGISTRY_LIMIT { + anyhow::bail!("workspace limit reached ({WORKSPACE_REGISTRY_LIMIT})"); + } + if state.workspace_by_key(&key).is_some() { + anyhow::bail!("workspace key already exists: {key}"); + } + let name = name.unwrap_or_else(|| Self::default_workspace_name(&state)); + let index = state.workspaces.len(); + let selection_resync = !state.workspaces.is_empty(); + let mut desired = self.registry_projection(&state); + desired.push(RegistryWorkspace { + id: ws_id, + public_id: workspace_public_id.clone(), + key: key.clone(), + name: name.clone(), + group_key: self.session.clone(), + }); + let result = serde_json::json!({ + "workspace": ws_id, + "workspace_id": workspace_public_id.as_str(), + "key": key, + "index": index, + }); + let commit = if project_resource { + registry.commit_with_active_workspace( + mutation, + &fingerprint, + expected_generation, + expected_revision, + "workspace-added", + &key, + &desired, + Some(&workspace_public_id), + &result, + )? + } else { + registry.commit_for_resource_effect( + mutation, + &fingerprint, + expected_generation, + expected_revision, + "workspace-added", + &key, + &desired, + Some(&workspace_public_id), + &result, + )? }; - if let Some(mut task) = Self::run_cell_pixel_retry_task(&mux, task) { - task.attempts = task.attempts.saturating_add(1); - if task.attempts >= CELL_PIXEL_RETRY_MAX_ATTEMPTS { - if let Some(mux) = mux.upgrade() { - mux.finish_cell_pixel_retries(&task); - } - continue; - } - std::thread::sleep(cell_pixel_retry_delay(task.attempts)); - let Some(mux) = mux.upgrade() else { return }; - let mut retries = mux.cell_pixel_retries.lock().unwrap(); - if retries.pending.is_none() { - retries.pending = Some(task); - } + let committed_workspace = commit.result["workspace"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("stored create result is missing workspace"))?; + let committed_key = commit.result["key"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("stored create result is missing key"))? + .to_string(); + let committed_index = commit.result["index"] + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| anyhow::anyhow!("stored create result is missing index"))?; + if commit.replayed { + return Ok(WorkspacePlacement { + workspace: committed_workspace, + key: committed_key, + index: committed_index, + revision: commit.revision, + replayed: true, + }); + } + let resource_revision = project_resource + .then(|| registry.snapshot()) + .transpose()? + .map(|snapshot| snapshot.resource_revision); + state.push_workspace(Workspace { + id: ws_id, + public_id: workspace_public_id, + key: key.clone(), + name, + screens: Vec::new(), + active_screen: 0, + }); + state.active_workspace = state.workspaces.len() - 1; + state.workspace_revision = commit.revision; + if let Some(resource_revision) = resource_revision { + state.resource_revision = resource_revision; } + let revision = commit.revision; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::WorkspaceAdded, + ws_id, + ) + .expect("new empty workspace is present in tree snapshot"); + ( + WorkspacePlacement { workspace: ws_id, key, index, revision, replayed: false }, + TreeDelta { + kind: TreeDeltaKind::WorkspaceAdded, + workspace: ws_id, + screen: None, + pane: None, + surface: None, + index: Some(index), + entity, + workspace_revision: Some(revision), + }, + selection_resync, + ) + }; + self.emit_committed_workspace_delta(®istry, delta, selection_resync); + drop(registry); + if project_resource { + self.publish_resource_event(); } + Ok(placement) + } + + pub fn run_command_surface( + self: &Arc, + argv: Vec, + pane: Option, + new_workspace: bool, + cwd: Option, + name: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result { + self.run_command_surface_with_options( + argv, + RunCommandOptions { pane, new_workspace, workspace_key: None, cwd, name, size }, + ) } - fn finish_cell_pixel_retries(&self, task: &CellPixelRetryTask) { - let remaining = { - let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); - let mut pending = self.pending_cell_pixels.lock().unwrap(); - let Some(pending) = pending.as_mut().filter(|pending| { - pending.generation == task.generation && pending.target == task.target - }) else { - return; - }; - pending.use_for_creation = false; - pending.failures.len() - }; - self.emit(MuxEvent::GraphicsStatus(GraphicsStatus::CellPixelUpdateRetriesExhausted { - attempts: task.attempts, - remaining, - cell_pixels: task.target, - })); + /// Runs a command and optionally creates its workspace with a caller-owned + /// stable key. The key is only meaningful when `new_workspace` is true. + pub(crate) fn run_command_surface_with_options( + self: &Arc, + argv: Vec, + options: RunCommandOptions, + ) -> anyhow::Result { + self.run_command_result_with_options(argv, options)? + .placement + .context("command exited before its surface could be returned") } - fn run_cell_pixel_retry_task( - mux: &Weak, - task: CellPixelRetryTask, - ) -> Option { - let mut retry_candidates = task.surfaces; - let mut pending_operations = Vec::new(); - for pending in task.pending { - let Some(result) = pending.result.try_take() else { - pending_operations.push(pending); - continue; - }; - let mux = mux.upgrade()?; - let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); - if !mux.pending_cell_pixels.lock().unwrap().as_ref().is_some_and(|update| { - update.generation == task.generation && update.target == task.target - }) { - return None; - } - let (surface_id, _, result, deferred) = result; - match result { - Ok(_) => mux.reconcile_cell_pixel_completion_locked( - surface_id, - task.generation, - task.target, - ), - Err(_) if deferred => {} - Err(_) => retry_candidates.push(pending.surface), - } + pub(crate) fn run_command_result_with_options( + self: &Arc, + argv: Vec, + options: RunCommandOptions, + ) -> anyhow::Result { + let _creation = self.begin_surface_creation()?; + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + let RunCommandOptions { pane, new_workspace, workspace_key, cwd, name, size } = options; + if workspace_key.is_some() && !new_workspace { + anyhow::bail!("workspace key requires a new workspace"); } - let mut unique = HashSet::new(); - retry_candidates - .retain(|surface| surface.upgrade().is_some_and(|surface| unique.insert(surface.id))); - let mut remaining = Vec::new(); - for retry_wave in retry_candidates.chunks(CELL_PIXEL_FANOUT_MAX_WORKERS) { - let mux = mux.upgrade()?; - let active = { - let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); - let pending = mux.pending_cell_pixels.lock().unwrap(); - let pending = pending.as_ref().filter(|pending| { - pending.generation == task.generation - && pending.target == task.target - && !pending.failures.is_empty() - })?; - retry_wave - .iter() - .filter_map(Weak::upgrade) - .filter(|surface| pending.failures.contains(&surface.id)) - .collect::>() - }; - let deadline = Instant::now() + task.timeout; - let report = task.report.clone(); - #[cfg(test)] - let operation_hook = task.operation_hook.clone(); - let target = task.target; - let completion = task.completion.clone(); - let completion_mux = Arc::downgrade(&mux); - let results = bounded_deadline_map( - &mux.deadline_fanout_pool, - &active, - deadline, - move |surface, deadline| { - let result = apply_cell_pixel_size_until( - surface, - target, - deadline, - &report, - #[cfg(test)] - operation_hook.as_ref(), - ); - let completed_at = Instant::now(); - if completed_at <= deadline - && result.2.is_ok() - && let Some(mux) = completion_mux.upgrade() - { - mux.record_cell_pixel_completion(&completion, surface.id); - } - result - }, - ); - let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); - if !mux.pending_cell_pixels.lock().unwrap().as_ref().is_some_and(|pending| { - pending.generation == task.generation && pending.target == task.target - }) { - return None; - } - for (surface, result) in active.iter().zip(results) { - let (surface_id, result, deferred) = match result { - DeadlineMapResult::Complete((surface_id, _, result, deferred)) => { - (surface_id, Some(result), deferred) - } - DeadlineMapResult::Pending(result) => { - pending_operations.push(PendingCellPixelOperation { - surface: Arc::downgrade(surface), - result, - }); - continue; - } - DeadlineMapResult::Unscheduled => { - remaining.push(Arc::downgrade(surface)); - continue; - } - }; - match result.expect("complete deadline result has an operation result") { - Ok(_) => mux.reconcile_cell_pixel_completion_locked( - surface_id, - task.generation, - task.target, - ), - Err(_) if deferred => {} - Err(error) - if error - .downcast_ref::< - crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed, - >() - .is_some() => - { - remaining.push(Arc::downgrade(surface)); - } - Err(_) => { - if let Some(pending) = mux - .pending_cell_pixels - .lock() - .unwrap() - .as_mut() - .filter(|pending| { - pending.generation == task.generation - && pending.target == task.target - }) - { - pending.use_for_creation = false; - } + let (operation, selectors) = if new_workspace { + (ResourceOperation::WorkspaceCreate, Self::ordinary_resource_selectors()) + } else { + let state = self.state.lock().unwrap(); + let target = match pane { + Some(id) => { + if !state.panes.contains_key(&id) { + anyhow::bail!("unknown pane {id}"); } + Some(id) } + None => state.active_pane(), + }; + if let Some(target) = target { + drop(state); + ( + ResourceOperation::PaneRun, + self.ordinary_pane_selectors(target) + .with_context(|| format!("unknown pane {target}"))?, + ) + } else if let Some(workspace) = state.workspaces.get(state.active_workspace) { + let workspace = workspace.id; + drop(state); + ( + ResourceOperation::WorkspaceRun, + self.ordinary_workspace_selectors(workspace) + .with_context(|| format!("unknown workspace {workspace}"))?, + ) + } else { + (ResourceOperation::WorkspaceCreate, Self::ordinary_resource_selectors()) } + }; + let mut fields = Map::from_iter([( + "argv".into(), + Value::Array(argv.into_iter().map(Value::String).collect()), + )]); + if operation == ResourceOperation::WorkspaceCreate { + fields.insert("initial_content".into(), Value::String("terminal".into())); + Self::insert_optional_string(&mut fields, "name", name.clone()); + Self::insert_optional_string(&mut fields, "workspace_key", workspace_key); + Self::insert_optional_string(&mut fields, "terminal_name", name); + } else { + Self::insert_optional_string(&mut fields, "name", name); } - let pending_ids = pending_operations - .iter() - .filter_map(|pending| pending.surface.upgrade()) - .map(|surface| surface.id) - .collect::>(); - let mut unique = HashSet::new(); - remaining.retain(|surface| { - surface.upgrade().is_some_and(|surface| { - !pending_ids.contains(&surface.id) && unique.insert(surface.id) - }) - }); - (!remaining.is_empty() || !pending_operations.is_empty()).then_some(CellPixelRetryTask { - surfaces: remaining, - pending: pending_operations, - attempts: task.attempts, - generation: task.generation, - target: task.target, - completion: task.completion, - report: task.report, - timeout: task.timeout, - #[cfg(test)] - operation_hook: task.operation_hook, - }) - } - - pub fn set_cell_pixel_size_reporting( - self: &Arc, - width_px: u16, - height_px: u16, - report: SurfaceResizeReporter, - ) -> CellPixelUpdate { - let cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); - let generation = self.next_cell_pixel_generation.fetch_add(1, Ordering::Relaxed); - let next = (width_px.max(1), height_px.max(1)); - let completion = Arc::new(CellPixelCompletionTracker { - generation, - target: next, - publishing: AtomicBool::new(true), - completed: Mutex::new(HashSet::new()), - }); - let mut surfaces = unique_surface_runtimes(&self.state.lock().unwrap()); - surfaces.sort_unstable_by_key(|surface| surface.id); - #[cfg(test)] - let timeout = self - .cell_pixel_fanout_timeout + Self::insert_optional_string(&mut fields, "cwd", cwd); + Self::insert_cell_size(&mut fields, size); + let commit = self.commit_ordinary_topology_operation(operation, selectors, fields)?; + self.emit_resource_topology_legacy_events(operation, &commit); + let terminal_id = self.created_terminal_host_id(&commit.result)?; + let surface = self.ordinary_created_surface(&commit).ok(); + drop(_creation_handoff); + if let Some(surface) = surface.as_ref() { + self.reap_if_dead(surface); + } + self.created_terminal_run_result(&terminal_id) + } + + fn created_terminal_host_id(&self, created_path: &Value) -> anyhow::Result { + let terminal_id = TerminalPublicId::parse( + created_path["terminal_id"] + .as_str() + .context("created terminal result omitted its public terminal id")? + .to_string(), + )?; + self.workspace_registry .lock() .unwrap() - .unwrap_or(crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT); - #[cfg(not(test))] - let timeout = crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; - let deadline = Instant::now() + timeout; - #[cfg(test)] - let operation_hook = self.cell_pixel_operation.lock().unwrap().clone(); - #[cfg(test)] - let fanout_operation_hook = operation_hook.clone(); - let operation_report = report.clone(); - let operation_completion = completion.clone(); - let operation_mux = Arc::downgrade(self); - let results = bounded_deadline_map( - &self.deadline_fanout_pool, - &surfaces, - deadline, - move |surface, deadline| { - let result = apply_cell_pixel_size_until( - surface, - next, - deadline, - &operation_report, - #[cfg(test)] - fanout_operation_hook.as_ref(), - ); - let completed_at = Instant::now(); - if completed_at <= deadline - && result.2.is_ok() - && let Some(mux) = operation_mux.upgrade() - { - mux.record_cell_pixel_completion(&operation_completion, surface.id); - } - result - }, - ); - let mut update = CellPixelUpdate::default(); - let mut retry_surfaces = Vec::new(); - let mut pending_operations = Vec::new(); - for (surface, result) in surfaces.iter().zip(results) { - let (id, size, result, deferred) = match result { - DeadlineMapResult::Complete(result) => result, - DeadlineMapResult::Pending(result) => { - pending_operations.push(PendingCellPixelOperation { - surface: Arc::downgrade(surface), - result, - }); - update.failures.push(CellPixelUpdateFailure { - surface: surface.id, - error: "cell pixel update is still running after the shared deadline" - .to_string(), - deferred: true, - }); - continue; - } - DeadlineMapResult::Unscheduled => { - retry_surfaces.push(Arc::downgrade(surface)); - update.failures.push(CellPixelUpdateFailure { - surface: surface.id, - error: "cell pixel update was deferred because the deadline worker pool \ - is saturated" - .to_string(), - deferred: true, + .terminal_host_id(&terminal_id)? + .context("created terminal result has no durable host id") + } + + pub(crate) fn created_terminal_run_result( + &self, + terminal_id: &str, + ) -> anyhow::Result { + for _ in 0..2 { + let resolution = self + .resolve_terminal(terminal_id)? + .context("created terminal result has no durable terminal row")?; + let placement = resolution.surface.and_then(|surface| { + self.with_state(|state| run_placement_for_surface(state, surface)) + }); + match resolution.terminal.lifecycle { + TerminalLifecycle::Exited => { + anyhow::ensure!( + resolution.terminal.exit.is_some(), + "exited terminal omitted durable exit metadata" + ); + return Ok(RunCommandResult { + placement: None, + terminal: resolution.terminal, + terminal_revision: resolution.terminal_revision, }); - continue; } - }; - match result { - Ok(Some(reservation_id)) => update.resizes.push((id, size, reservation_id)), - Ok(None) => {} - Err(error) => { - let retry = error - .downcast_ref::< - crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed, - >() - .is_some(); - if retry { - retry_surfaces.push(Arc::downgrade(surface)); + TerminalLifecycle::Running => { + if let Some(placement) = placement { + return Ok(RunCommandResult { + placement: Some(placement), + terminal: resolution.terminal, + terminal_revision: resolution.terminal_revision, + }); } - update.failures.push(CellPixelUpdateFailure { - surface: id, - error: error.to_string(), - deferred: deferred || retry, - }); + // Exit commits lifecycle before installing the detached + // state. Re-read once if that transition landed between + // resolve_terminal's registry and state snapshots. } + lifecycle => anyhow::bail!( + "created terminal is {} before its run result could be returned", + terminal_lifecycle_name(lifecycle) + ), } } - let completed = std::mem::take(&mut *completion.completed.lock().unwrap()); - if !completed.is_empty() { - update.failures.retain(|failure| !completed.contains(&failure.surface)); - retry_surfaces.retain(|surface| { - surface.upgrade().is_some_and(|surface| !completed.contains(&surface.id)) - }); - pending_operations.retain(|pending| { - pending.surface.upgrade().is_some_and(|surface| !completed.contains(&surface.id)) - }); + anyhow::bail!("created running terminal has no placement") + } + + pub(crate) fn reap_created_terminal_surface(self: &Arc, surface: Option) { + if let Some(surface) = surface.and_then(|surface| self.surface(surface)) { + self.reap_if_dead(&surface); } - #[cfg(test)] - if let Some(hook) = self.cell_pixel_before_publish.lock().unwrap().clone() { - hook(self.cell_pixel_size()); + } + + pub(crate) fn activate_created_terminal_surface( + &self, + surface: Option, + ) -> anyhow::Result<()> { + if let Some(surface) = surface.and_then(|surface| self.surface(surface)) { + surface.activate_hosted_launch_stream()?; } - // Keep the published value at the last fully converged metric. New - // surfaces use the pending target, and late hosted acknowledgements - // remove their exact failure before publishing it globally. - if update.failures.is_empty() { - *self.cell_pixels.lock().unwrap() = next; - *self.pending_cell_pixels.lock().unwrap() = None; + Ok(()) + } + + /// Create a screen in a workspace (default: the active one) with one + /// pane/tab, and make it active. Returns the tab's surface. + pub fn new_screen( + self: &Arc, + workspace: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result> { + self.new_screen_with_cwd(workspace, None, size) + } + + fn new_screen_with_cwd( + self: &Arc, + workspace: Option, + cwd: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result> { + let _creation = self.begin_surface_creation()?; + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + let selectors = match workspace { + Some(workspace) => self + .ordinary_workspace_selectors(workspace) + .with_context(|| format!("unknown workspace {workspace}"))?, + None => { + let active = self.with_state(|state| { + state.workspaces.get(state.active_workspace).map(|workspace| workspace.id) + }); + active + .and_then(|workspace| self.ordinary_workspace_selectors(workspace)) + .unwrap_or_else(Self::ordinary_resource_selectors) + } + }; + let mut fields = Map::new(); + Self::insert_optional_string(&mut fields, "cwd", cwd); + Self::insert_cell_size(&mut fields, size); + let commit = self.commit_ordinary_topology_operation( + ResourceOperation::ScreenCreate, + selectors, + fields, + )?; + self.emit_resource_topology_legacy_events(ResourceOperation::ScreenCreate, &commit); + self.ordinary_created_surface(&commit) + } + + /// Create a tab in a pane (default: the active pane of the active + /// screen). When the session has no workspaces yet (headless before + /// any command), a workspace is created around the new tab. + pub fn new_tab( + self: &Arc, + pane: Option, + cwd: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result> { + let _creation = self.begin_surface_creation()?; + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + let selectors = { + let state = self.state.lock().unwrap(); + let target = match pane { + Some(id) => { + if !state.panes.contains_key(&id) { + anyhow::bail!("unknown pane {id}"); + } + Some(id) + } + None => state.active_pane(), + }; + if let Some(target) = target { + drop(state); + self.ordinary_pane_selectors(target) + .with_context(|| format!("unknown pane {target}"))? + } else if let Some(workspace) = state.workspaces.get(state.active_workspace) { + let workspace = workspace.id; + drop(state); + self.ordinary_workspace_selectors(workspace) + .with_context(|| format!("unknown workspace {workspace}"))? + } else { + Self::ordinary_resource_selectors() + } + }; + let mut fields = Map::new(); + Self::insert_optional_string(&mut fields, "cwd", cwd); + Self::insert_cell_size(&mut fields, size); + let commit = self.commit_ordinary_topology_operation( + ResourceOperation::TabCreateTerminal, + selectors, + fields, + )?; + self.emit_resource_topology_legacy_events(ResourceOperation::TabCreateTerminal, &commit); + self.ordinary_created_surface(&commit) + } + + /// Create a terminal in a specific workspace without changing the mux's + /// active workspace. An empty workspace gets its first screen and pane; + /// otherwise the new surface becomes a tab in that workspace's active + /// pane. The target is re-resolved under the attach lock so concurrent + /// first-terminal requests cannot accidentally create another workspace. + pub fn create_terminal_in_workspace( + self: &Arc, + workspace: WorkspaceId, + argv: Option>, + cwd: Option, + name: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result { + self.create_terminal_surface_in_workspace(workspace, argv, cwd, name, size) + .map(|(_, placement)| placement) + } + + pub(crate) fn create_terminal_result_in_workspace( + self: &Arc, + workspace: WorkspaceId, + argv: Option>, + cwd: Option, + name: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result { + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + let selectors = self + .ordinary_workspace_selectors(workspace) + .with_context(|| format!("unknown workspace {workspace}"))?; + let operation = if argv.is_some() { + ResourceOperation::WorkspaceRun } else { - *self.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { - generation, - target: next, - failures: update.failures.iter().map(|failure| failure.surface).collect(), - use_for_creation: update.failures.iter().all(|failure| failure.deferred), - }); - } - completion.publishing.store(false, Ordering::Release); - let raced_completions = std::mem::take(&mut *completion.completed.lock().unwrap()); - for surface in &raced_completions { - self.reconcile_cell_pixel_completion_locked(*surface, generation, next); + ResourceOperation::TabCreateTerminal + }; + let mut fields = Map::new(); + if let Some(argv) = argv { + fields + .insert("argv".into(), Value::Array(argv.into_iter().map(Value::String).collect())); } - if !raced_completions.is_empty() { - update.failures.retain(|failure| !raced_completions.contains(&failure.surface)); - retry_surfaces.retain(|surface| { - surface.upgrade().is_some_and(|surface| !raced_completions.contains(&surface.id)) - }); - pending_operations.retain(|pending| { - pending - .surface - .upgrade() - .is_some_and(|surface| !raced_completions.contains(&surface.id)) - }); + Self::insert_optional_string(&mut fields, "cwd", cwd); + Self::insert_optional_string(&mut fields, "name", name); + Self::insert_cell_size(&mut fields, size); + let commit = self.commit_ordinary_topology_operation(operation, selectors, fields)?; + self.emit_resource_topology_legacy_events(operation, &commit); + let terminal_id = self.created_terminal_host_id(&commit.result)?; + let surface = self.ordinary_created_surface(&commit).ok(); + drop(_creation_handoff); + if let Some(surface) = surface.as_ref() { + self.reap_if_dead(surface); } - let retry_ids = retry_surfaces - .iter() - .filter_map(Weak::upgrade) - .map(|surface| surface.id) - .chain( - pending_operations - .iter() - .filter_map(|pending| pending.surface.upgrade()) - .map(|surface| surface.id), - ) - .collect::>(); - let retry_spawn = if retry_surfaces.is_empty() && pending_operations.is_empty() { - Ok(()) + self.created_terminal_run_result(&terminal_id) + } + + fn create_terminal_surface_in_workspace( + self: &Arc, + workspace: WorkspaceId, + argv: Option>, + cwd: Option, + name: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result<(Arc, RunPlacement)> { + let _creation = self.begin_surface_creation()?; + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + let selectors = self + .ordinary_workspace_selectors(workspace) + .with_context(|| format!("unknown workspace {workspace}"))?; + let operation = if argv.is_some() { + ResourceOperation::WorkspaceRun } else { - self.enqueue_cell_pixel_retries(CellPixelRetryTask { - surfaces: retry_surfaces, - pending: pending_operations, - attempts: 0, - generation, - target: next, - completion, - report, - timeout, - #[cfg(test)] - operation_hook, - }) + ResourceOperation::TabCreateTerminal }; - if let Err(error) = retry_spawn { - for failure in &mut update.failures { - if retry_ids.contains(&failure.surface) { - failure.deferred = false; - failure.error = format!("{}; could not schedule retry: {error}", failure.error); - } - } - if let Some(pending) = self.pending_cell_pixels.lock().unwrap().as_mut() { - pending.use_for_creation = update.failures.iter().all(|failure| failure.deferred); - } + let mut fields = Map::new(); + if let Some(argv) = argv { + fields + .insert("argv".into(), Value::Array(argv.into_iter().map(Value::String).collect())); } - drop(cell_pixel_lifecycle); - update + Self::insert_optional_string(&mut fields, "cwd", cwd); + Self::insert_optional_string(&mut fields, "name", name); + Self::insert_cell_size(&mut fields, size); + let commit = self.commit_ordinary_topology_operation(operation, selectors, fields)?; + self.emit_resource_topology_legacy_events(operation, &commit); + let surface = self.ordinary_created_surface(&commit)?; + let placement = self + .with_state(|state| run_placement_for_surface(state, surface.id)) + .context("created terminal has no placement")?; + Ok((surface, placement)) } - pub fn default_colors(&self) -> DefaultColors { - *self.default_colors.lock().unwrap() + #[allow(clippy::too_many_arguments)] + pub(crate) fn create_raw_terminal_in_workspace_with_mutation( + self: &Arc, + workspace: WorkspaceId, + argv: Option>, + cwd: Option, + name: Option, + size: Option<(u16, u16)>, + requested_terminal_id: Option<&str>, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + let _creation_execution = self.resource_creation_execution.lock().unwrap(); + self.create_terminal_in_workspace_with_mutation( + workspace, + argv, + cwd, + name, + size, + requested_terminal_id, + expected_generation, + expected_revision, + mutation, + ) } - pub fn set_default_colors(&self, colors: DefaultColors) { - let surfaces = { - let state = self.state.lock().unwrap(); - let mut current = self.default_colors.lock().unwrap(); - if *current == colors { - return; - } - *current = colors; - unique_surface_runtimes(&state) - }; - for surface in surfaces { - surface.set_default_colors(colors); - self.emit_terminal_output(surface.id); + #[allow(clippy::too_many_arguments)] + pub fn create_terminal_in_workspace_with_mutation( + self: &Arc, + workspace: WorkspaceId, + argv: Option>, + cwd: Option, + name: Option, + size: Option<(u16, u16)>, + requested_terminal_id: Option<&str>, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + let workspace_key = self + .state + .lock() + .unwrap() + .workspace_by_id(workspace) + .map(|workspace| workspace.key.clone()) + .ok_or_else(|| anyhow::anyhow!("unknown workspace {workspace}"))?; + if let Some(terminal_id) = requested_terminal_id { + validate_terminal_hex(terminal_id, "invalid_terminal_id")?; + } + let fingerprint = terminal_create_fingerprint( + &workspace_key, + requested_terminal_id, + argv.as_deref(), + cwd.as_deref(), + name.as_deref(), + size, + )?; + let replay = + { self.workspace_registry.lock().unwrap().replay_terminal(mutation, &fingerprint)? }; + if let Some(replay) = replay { + let terminal_id = replay.result["terminal_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("stored terminal create result is missing id"))?; + return self.replayed_terminal_placement(terminal_id); } + let terminal_id = match requested_terminal_id { + Some(value) => TerminalId::from_hex(value).expect("validated terminal UUID"), + None => TerminalId::random()?, + }; + let reservation = TerminalReservationRequest { + terminal_id, + mutation: mutation.clone(), + fingerprint, + expected_generation: expected_generation.map(str::to_string), + expected_revision, + }; + let (placement, surface, created_path) = self.create_terminal_in_workspace_impl( + workspace, + argv, + cwd, + name, + size, + Some(reservation), + )?; + let identity = self + .resource_terminal_host_identity(&surface) + .ok_or_else(|| anyhow::anyhow!("created terminal has no host identity"))?; + let snapshot = self.workspace_registry.lock().unwrap().terminal_snapshot()?; + Ok(TerminalPlacementResult { + placement: Some(placement), + terminal_id: identity.terminal_id, + terminal_incarnation: Some(identity.incarnation), + terminal_revision: snapshot.revision, + replayed: false, + created_path: Some(created_path), + created_surface: Some(surface.id), + }) + } + + fn replayed_terminal_placement( + &self, + terminal_id: &str, + ) -> anyhow::Result { + let resolved = self.created_terminal_run_result(terminal_id)?; + let placement = resolved.placement; + let surface = placement.map(|placement| placement.surface); + let created_path = + surface.map(|surface| self.created_resource_path(surface)).transpose()?; + Ok(TerminalPlacementResult { + placement, + terminal_id: resolved.terminal.terminal_id, + terminal_incarnation: resolved.terminal.incarnation, + terminal_revision: resolved.terminal_revision, + replayed: true, + created_path, + created_surface: surface, + }) } - pub fn seed_default_colors_if_no_durable_override(&self, colors: DefaultColors) { - let surfaces = { + fn create_terminal_in_workspace_impl( + self: &Arc, + workspace: WorkspaceId, + argv: Option>, + cwd: Option, + name: Option, + size: Option<(u16, u16)>, + reservation: Option, + ) -> anyhow::Result<(RunPlacement, Arc, Value)> { + let _creation = self.begin_surface_creation()?; + { let state = self.state.lock().unwrap(); - let mut current = self.default_colors.lock().unwrap(); - if self.durable_terminal_defaults.load(Ordering::Acquire) || *current == colors { - return; + if state.workspace_by_id(workspace).is_none() { + anyhow::bail!("unknown workspace {workspace}"); } - *current = colors; - unique_surface_runtimes(&state) - }; - for surface in surfaces { - surface.set_default_colors(colors); - self.emit_terminal_output(surface.id); } - } - - #[allow(clippy::too_many_arguments)] - pub(crate) fn resource_update_terminal_defaults_selected( - &self, - selectors: crate::ResourceSelectors, - fields: &Value, - colors: DefaultColors, - value: &Value, - expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - let mut intent_fields = fields.clone(); - if let Some(fields) = intent_fields.as_object_mut() { - fields.remove("expected_revision"); + #[cfg(test)] + if let Some(hook) = self.terminal_create_after_empty_check.lock().unwrap().clone() { + hook(); } - let fingerprint = serde_json::json!({ - "operation":"session.terminal_defaults.update", - "selectors":selectors, - "fields":intent_fields, - }); - let mut registry = self.workspace_registry.lock().unwrap(); - if let Some(replay) = registry.replay_resource_patch( - mutation, - "session.terminal_defaults.update", - &fingerprint, - )? { - return Ok(replay); + let lifecycle = self.workspace_lifecycle(workspace); + let workspace_lifecycle = lifecycle.lock().unwrap(); + #[cfg(test)] + if let Some(hook) = self.terminal_create_after_materialization_lock.lock().unwrap().clone() + { + hook(); } - let mut state = self.state.lock().unwrap(); - self.resolve_resource_path_in_state( - &state, - ®istry, - crate::ResourceTarget::Session, - &selectors, - ) - .map_err(anyhow::Error::new)?; - let surfaces = unique_surface_runtimes(&state); - let commit = registry.commit_resource_patch( - mutation, - "session.terminal_defaults.update", - &fingerprint, - None, - expected_revision, - &ResourcePatch { changes: Vec::new() }, - value, - &Value::Array(Vec::new()), - )?; - state.resource_revision = commit.revision; - self.durable_terminal_defaults.store(true, Ordering::Release); - *self.default_colors.lock().unwrap() = colors; - for surface in &surfaces { - surface.set_default_colors(colors); + #[cfg(test)] + if let Some(hook) = self.terminal_create_after_workspace_reservation.lock().unwrap().clone() + { + hook(); } - drop(state); - drop(registry); - for surface in surfaces { - self.emit_terminal_output(surface.id); + let (workspace_key, inherited_pane) = { + let state = self.state.lock().unwrap(); + let Some(workspace) = state.workspace_by_id(workspace) else { + anyhow::bail!("unknown workspace {workspace}"); + }; + (workspace.key.clone(), workspace.active_screen_ref().map(|screen| screen.active_pane)) + }; + let inherited_cwd = inherited_pane.and_then(|pane| self.pane_cwd(pane)); + let surface = match reservation { + Some(reservation) => self.spawn_surface_in_workspace_reserved( + &workspace_key, + cwd.or(inherited_cwd), + size, + argv, + reservation, + )?, + None => { + self.spawn_surface_in_workspace(&workspace_key, cwd.or(inherited_cwd), size, argv)? + } + }; + self.pending_workspace_surfaces.lock().unwrap().insert(surface.id, workspace); + let pending_surface = self.pending_workspace_surface(surface.id); + if let Some(name) = name { + surface.set_name(Some(name)); } - if !commit.replayed { - self.publish_resource_event(); + if surface.terminal_host_identity().is_some() { + // Launch/Ready intentionally releases the registry lock around + // process startup. Re-read canonical placement after Ready and + // hold registry -> state through the binding so a move committed + // during launch is projected instead of the stale request target. + let projected = self.bind_running_terminal_to_canonical_workspace(&surface); + let (placement, canonical_workspace, changed, created_path) = match projected { + Ok(projected) => projected, + Err(error) => { + self.fail_hosted_terminal_attachment( + &surface, + "terminal-topology-attach-failed", + "topology-attach-failed", + )?; + return Err(error); + } + }; + let _ = surface.persist_host_workspace(&canonical_workspace); + if changed { + self.emit(MuxEvent::TreeChanged); + } + drop(pending_surface); + drop(workspace_lifecycle); + return Ok((placement, surface, created_path)); } - Ok(commit) - } - - /// Resize a surface and broadcast the final clamped size when it actually - /// changes. Browser workers broadcast after their asynchronous CDP work. - pub fn resize_surface(&self, id: SurfaceId, cols: u16, rows: u16) -> anyhow::Result { - self.resize_surface_with_reservation(id, cols, rows).map(|(accepted, _)| accepted) - } - - pub fn resize_surface_with_reservation( - &self, - id: SurfaceId, - cols: u16, - rows: u16, - ) -> anyhow::Result<(bool, Option)> { - self.resize_surface_with_completion(id, cols, rows, None) + let notifications = self.surface_notifications(); + let active_at = self.next_active_at(); + let mut rollback_removed = Vec::new(); + let attached = { + let mut state = self.state.lock().unwrap(); + let result = (|| -> anyhow::Result<_> { + anyhow::ensure!( + state.surfaces.contains_key(&surface.id), + "terminal closed while its topology binding was being created" + ); + let wi = state + .workspace_index(workspace) + .context("workspace disappeared while creating terminal")?; + let target = + state.workspaces[wi].active_screen_ref().map(|screen| screen.active_pane); + if let Some(target) = target { + let (_, si) = state + .screen_of(target) + .context("workspace active pane disappeared while creating terminal")?; + let pane = state + .panes + .get_mut(&target) + .context("workspace active pane disappeared while creating terminal")?; + pane.tabs.push(surface.id); + pane.active_tab = pane.tabs.len() - 1; + pane.active_at = active_at; + let index = pane.tabs.len() - 1; + fence_layout_undo_for_tab_membership(&mut state, &[target]); + let screen = state.workspaces[wi].screens[si].id; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::TabAdded, + surface.id, + ) + .expect("new terminal tab is present in tree snapshot"); + let placement = + RunPlacement { surface: surface.id, pane: target, screen, workspace }; + let created_path = self.created_resource_path_in_state(&state, surface.id)?; + Ok(( + placement, + TreeDelta { + kind: TreeDeltaKind::TabAdded, + workspace, + screen: Some(screen), + pane: Some(target), + surface: Some(surface.id), + index: Some(index), + entity, + workspace_revision: None, + }, + true, + created_path, + )) + } else { + let (pane_id, pane) = self.make_pane(surface.id)?; + let screen_id = self.next_id(); + let screen_public_id = ScreenPublicId::random()?; + state.insert_pane(pane); + stamp_pane_focus(self, &mut state, pane_id); + state.workspaces[wi].screens.push(Screen { + id: screen_id, + public_id: screen_public_id, + name: None, + root: Node::Leaf(pane_id), + active_pane: pane_id, + zoomed_pane: None, + zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), + }); + state.workspaces[wi].active_screen = 0; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::ScreenAdded, + screen_id, + ) + .expect("first workspace screen is present in tree snapshot"); + let placement = RunPlacement { + surface: surface.id, + pane: pane_id, + screen: screen_id, + workspace, + }; + let created_path = self.created_resource_path_in_state(&state, surface.id)?; + Ok(( + placement, + TreeDelta { + kind: TreeDeltaKind::ScreenAdded, + workspace, + screen: Some(screen_id), + pane: None, + surface: None, + index: Some(0), + entity, + workspace_revision: None, + }, + false, + created_path, + )) + } + })(); + if result.is_err() { + rollback_removed = remove_terminal_runtime_from_state(self, &mut state, &surface).0; + } + result + }; + let attached = match attached { + Ok(attached) => attached, + Err(error) => { + drop(pending_surface); + for placement in rollback_removed { + self.purge_surface_side_tables(placement.id); + } + self.purge_terminal_runtime_side_tables(&surface); + if !surface.is_dead() { + surface.kill(); + } + return Err(error); + } + }; + drop(pending_surface); + self.emit_tree_delta(attached.1, attached.2); + drop(workspace_lifecycle); + Ok((attached.0, surface, attached.3)) } - fn resize_surface_with_completion( + /// Bind a just-launched hosted surface using the latest durable row, not + /// the workspace requested before process launch. Holding registry -> + /// state through projection is the create/move serialization fence. + fn bind_running_terminal_to_canonical_workspace( &self, - id: SurfaceId, - cols: u16, - rows: u16, - completion: Option, - ) -> anyhow::Result<(bool, Option)> { - let Some(surface) = self.surface(id) else { - anyhow::bail!("unknown surface {id}"); - }; - // Not recorded as a client size here: internal resizes (e.g. the - // sidebar plugin surface tracking the TUI rect every frame) also land - // in this method and must not become the default for new surfaces. - // Client interactions record explicitly at the protocol/TUI layers. - let (cols, rows) = clamp_terminal_size(cols, rows); - if surface.as_browser().is_some() { - let reservation_id = - surface.resize_reporting_completion(cols, rows, Box::new(|_| {}), completion)?; - return Ok((reservation_id.is_some(), reservation_id)); - } - let reports_asynchronously = surface.resize_reports_asynchronously(); - if !surface.resize(cols, rows)? { - if let Some(completion) = completion { - let _ = completion.send(Ok(())); - } - return Ok((false, None)); - } - if reports_asynchronously { - return Ok((true, None)); + surface: &Arc, + ) -> anyhow::Result<(RunPlacement, String, bool, Value)> { + let identity = surface + .terminal_host_identity() + .ok_or_else(|| anyhow::anyhow!("created terminal has no host identity"))?; + let registry = self.workspace_registry.lock().unwrap(); + let terminal = registry + .terminal_record(&identity.terminal_id)? + .ok_or_else(|| anyhow::anyhow!("created terminal has no registry row"))?; + if terminal.lifecycle != TerminalLifecycle::Running { + anyhow::bail!( + "created terminal is {} before topology binding", + terminal_lifecycle_name(terminal.lifecycle) + ); } - if let Some(completion) = completion { - let _ = completion.send(Ok(())); + let mut state = self.state.lock().unwrap(); + if !state.surfaces.contains_key(&surface.id) { + anyhow::bail!("terminal closed while its topology binding was being created"); } - let (cols, rows) = surface.size(); - self.emit_terminal_resized(id, cols, rows, None); - Ok((true, None)) + let (placement, changed) = self.project_terminal_to_workspace_in_state( + &mut state, + &identity.terminal_id, + &terminal.workspace_key, + )?; + let placement = + placement.ok_or_else(|| anyhow::anyhow!("created terminal has no live surface"))?; + let created_path = self.created_resource_path_in_state(&state, surface.id)?; + Ok((placement, terminal.workspace_key, changed, created_path)) } - /// Create a workspace with one screen holding one pane with one tab. - /// Returns the tab's surface. `size` is the expected content size in - /// cells, when the caller knows it (spawning at the final size avoids - /// shell redraw artifacts). - pub fn new_workspace( + /// Create a browser tab in a pane (default: the active pane). When + /// the session has no workspaces yet, a workspace is created around + /// the browser tab. + pub fn new_browser_tab( self: &Arc, - name: Option, + url: String, + pane: Option, size: Option<(u16, u16)>, ) -> anyhow::Result> { + let _creation = self.begin_surface_creation()?; let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let mut fields = - Map::from_iter([("initial_content".into(), Value::String("terminal".into()))]); - Self::insert_optional_string(&mut fields, "name", name); - Self::insert_cell_size(&mut fields, size); + let selectors = { + let state = self.state.lock().unwrap(); + let target = match pane { + Some(id) => { + if !state.panes.contains_key(&id) { + anyhow::bail!("unknown pane {id}"); + } + Some(id) + } + None => state.active_pane(), + }; + if let Some(target) = target { + drop(state); + self.ordinary_pane_selectors(target) + .with_context(|| format!("unknown pane {target}"))? + } else if let Some(workspace) = state.workspaces.get(state.active_workspace) { + let workspace = workspace.id; + drop(state); + self.ordinary_workspace_selectors(workspace) + .with_context(|| format!("unknown workspace {workspace}"))? + } else { + Self::ordinary_resource_selectors() + } + }; + let mut fields = Map::from_iter([("url".into(), Value::String(url))]); + if let Some((cols, rows)) = size { + let (cell_width, cell_height) = self.cell_pixel_size(); + fields.insert("width_px".into(), Value::from(u64::from(cols) * u64::from(cell_width))); + fields + .insert("height_px".into(), Value::from(u64::from(rows) * u64::from(cell_height))); + } let commit = self.commit_ordinary_topology_operation( - ResourceOperation::WorkspaceCreate, - Self::ordinary_resource_selectors(), + ResourceOperation::TabCreateBrowser, + selectors, fields, )?; - self.emit_resource_topology_legacy_events(ResourceOperation::WorkspaceCreate, &commit); + self.emit_resource_topology_legacy_events(ResourceOperation::TabCreateBrowser, &commit); self.ordinary_created_surface(&commit) } - /// Add an ordered workspace-registry entry without creating a PTY, - /// screen, or pane. Detached GUI frontends use this when a user creates - /// an empty workspace in Chrome. - pub fn create_empty_workspace( - &self, - name: Option, - key: Option, - expected_revision: Option, - ) -> anyhow::Result { - let mutation = WorkspaceMutation::local("cmux-tui"); - self.create_empty_workspace_with_mutation(name, key, None, expected_revision, &mutation) - } - - pub fn create_empty_workspace_with_mutation( - &self, - name: Option, - requested_key: Option, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - self.create_empty_workspace_with_mutation_inner( - name, - requested_key, - None, - expected_generation, - expected_revision, - mutation, - true, - ) - } - - fn create_empty_workspace_for_resource_effect( - &self, - name: Option, - requested_key: Option, - public_id: WorkspacePublicId, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - self.create_empty_workspace_with_mutation_inner( - name, - requested_key, - Some(public_id), - None, - None, - mutation, - false, + pub(crate) fn new_browser_tab_reserved( + self: &Arc, + url: String, + pane: Option, + size: Option<(u16, u16)>, + resource_identity: TabResourceIdentity, + workspace_key: Option, + ) -> anyhow::Result> { + self.new_browser_tab_with_resource_identity( + url, + pane, + size, + Some(resource_identity), + workspace_key, ) } - #[allow(clippy::too_many_arguments)] - fn create_empty_workspace_with_mutation_inner( - &self, - name: Option, - requested_key: Option, - requested_public_id: Option, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - project_resource: bool, - ) -> anyhow::Result { - if let Some(name) = name.as_deref() { - Self::validate_workspace_name(name)?; - } - let key = match requested_key.as_ref() { - Some(key) if key.trim().is_empty() => anyhow::bail!("workspace key cannot be empty"), - Some(key) if !crate::workspace_registry::is_canonical_workspace_key(key) => { - anyhow::bail!("workspace key must be a lowercase UUID") - } - Some(key) => key.clone(), - None => Self::new_workspace_key()?, + fn new_browser_tab_with_resource_identity( + self: &Arc, + url: String, + pane: Option, + size: Option<(u16, u16)>, + resource_identity: Option, + workspace_key: Option, + ) -> anyhow::Result> { + let (target, empty_workspace) = { + let state = self.state.lock().unwrap(); + let target = match pane { + Some(id) => { + if !state.panes.contains_key(&id) { + anyhow::bail!("unknown pane {id}"); + } + Some(id) + } + None => state.active_pane(), + }; + let empty_workspace = target + .is_none() + .then(|| state.workspaces.get(state.active_workspace)) + .flatten() + .filter(|workspace| workspace.screens.is_empty()) + .map(|workspace| workspace.id); + (target, empty_workspace) }; - Self::validate_workspace_key(&key)?; - let requested_name = name.clone(); - let ws_id = self.next_id(); - let notifications = self.surface_notifications(); - let mut registry = self.workspace_registry.lock().unwrap(); - let fingerprint = serde_json::json!({ - "op": "create-workspace", - "name": requested_name, - "requested_key": requested_key, - }); - if let Some(commit) = registry.replay(mutation, &fingerprint)? { - let workspace = commit.result["workspace"] - .as_u64() - .ok_or_else(|| anyhow::anyhow!("stored create result is missing workspace"))?; - let key = commit.result["key"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("stored create result is missing key"))? - .to_string(); - let index = commit.result["index"] - .as_u64() - .and_then(|value| usize::try_from(value).ok()) - .ok_or_else(|| anyhow::anyhow!("stored create result is missing index"))?; - return Ok(WorkspacePlacement { - workspace, - key, - index, - revision: commit.revision, - replayed: true, - }); - } - let workspace_public_id = - requested_public_id.map(Ok).unwrap_or_else(WorkspacePublicId::random)?; - let (placement, delta, selection_resync) = { - let mut state = self.state.lock().unwrap(); - if state.workspaces.len() >= WORKSPACE_REGISTRY_LIMIT { - anyhow::bail!("workspace limit reached ({WORKSPACE_REGISTRY_LIMIT})"); + let Some(target) = target else { + if let Some(workspace) = empty_workspace { + return self.create_browser_surface_in_workspace( + workspace, + url, + size, + resource_identity, + ); } - if state.workspace_by_key(&key).is_some() { - anyhow::bail!("workspace key already exists: {key}"); + let workspace_key = match workspace_key { + Some(workspace_key) => workspace_key, + None => Self::new_workspace_key()?, + }; + let surface = self.spawn_browser_surface_with_resource_identity( + url, + size, + None, + resource_identity, + )?; + let (pane_id, pane) = self.make_pane(surface.id)?; + let screen_id = self.next_id(); + let ws_id = self.next_id(); + let notifications = self.surface_notifications(); + if let Some(workspace_id) = empty_workspace { + let delta = { + let mut state = self.state.lock().unwrap(); + let Some(workspace_index) = + state.workspaces.iter().position(|workspace| workspace.id == workspace_id) + else { + drop(state); + self.discard_spawned(vec![surface]); + anyhow::bail!("workspace disappeared while creating browser tab"); + }; + state.insert_pane(pane); + stamp_pane_focus(self, &mut state, pane_id); + state.workspaces[workspace_index].screens.push(Screen { + id: screen_id, + public_id: ScreenPublicId::random()?, + name: None, + root: Node::Leaf(pane_id), + active_pane: pane_id, + zoomed_pane: None, + zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), + }); + state.workspaces[workspace_index].active_screen = 0; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::ScreenAdded, + screen_id, + ) + .expect("first workspace screen is present in tree snapshot"); + TreeDelta { + kind: TreeDeltaKind::ScreenAdded, + workspace: workspace_id, + screen: Some(screen_id), + pane: None, + surface: None, + index: Some(0), + entity, + workspace_revision: None, + } + }; + self.emit(MuxEvent::TreeDelta(delta)); + self.reap_if_dead(&surface); + return Ok(surface); } - let name = name.unwrap_or_else(|| Self::default_workspace_name(&state)); - let index = state.workspaces.len(); - let selection_resync = !state.workspaces.is_empty(); - let mut desired = self.registry_projection(&state); - desired.push(RegistryWorkspace { - id: ws_id, - public_id: workspace_public_id.clone(), - key: key.clone(), - name: name.clone(), - group_key: self.session.clone(), - }); - let result = serde_json::json!({ - "workspace": ws_id, - "workspace_id": workspace_public_id.as_str(), - "key": key, - "index": index, - }); - let commit = if project_resource { - registry.commit_with_active_workspace( - mutation, - &fingerprint, - expected_generation, - expected_revision, - "workspace-added", - &key, - &desired, - Some(&workspace_public_id), - &result, - )? - } else { - registry.commit_for_resource_effect( - mutation, - &fingerprint, - expected_generation, - expected_revision, + let mutation = WorkspaceMutation::local("cmux-tui"); + let workspace_public_id = WorkspacePublicId::random()?; + let mut registry = self.workspace_registry.lock().unwrap(); + let delta = { + let mut state = self.state.lock().unwrap(); + let name = Self::default_workspace_name(&state); + let index = state.workspaces.len(); + let mut desired = self.registry_projection(&state); + desired.push(RegistryWorkspace { + id: ws_id, + public_id: workspace_public_id.clone(), + key: workspace_key.clone(), + name: name.clone(), + group_key: self.session.clone(), + }); + let commit = match registry.commit( + &mutation, + &serde_json::json!({ + "op": "new-browser-workspace", + "workspace": ws_id, + "key": workspace_key.clone(), + "name": name, + }), + None, + None, "workspace-added", - &key, + &workspace_key, &desired, - Some(&workspace_public_id), - &result, - )? - }; - let committed_workspace = commit.result["workspace"] - .as_u64() - .ok_or_else(|| anyhow::anyhow!("stored create result is missing workspace"))?; - let committed_key = commit.result["key"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("stored create result is missing key"))? - .to_string(); - let committed_index = commit.result["index"] - .as_u64() - .and_then(|value| usize::try_from(value).ok()) - .ok_or_else(|| anyhow::anyhow!("stored create result is missing index"))?; - if commit.replayed { - return Ok(WorkspacePlacement { - workspace: committed_workspace, - key: committed_key, - index: committed_index, - revision: commit.revision, - replayed: true, - }); - } - let resource_revision = project_resource - .then(|| registry.snapshot()) - .transpose()? - .map(|snapshot| snapshot.resource_revision); - state.push_workspace(Workspace { - id: ws_id, - public_id: workspace_public_id, - key: key.clone(), - name, - screens: Vec::new(), - active_screen: 0, - }); - state.active_workspace = state.workspaces.len() - 1; - state.workspace_revision = commit.revision; - if let Some(resource_revision) = resource_revision { - state.resource_revision = resource_revision; - } - let revision = commit.revision; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::WorkspaceAdded, - ws_id, - ) - .expect("new empty workspace is present in tree snapshot"); - ( - WorkspacePlacement { workspace: ws_id, key, index, revision, replayed: false }, + &serde_json::json!({ + "workspace": ws_id, + "workspace_id": workspace_public_id.as_str(), + "key": workspace_key.clone(), + "index": index, + }), + ) { + Ok(commit) => commit, + Err(error) => { + drop(state); + drop(registry); + self.discard_spawned(vec![surface]); + return Err(error); + } + }; + state.insert_pane(pane); + stamp_pane_focus(self, &mut state, pane_id); + state.push_workspace(Workspace { + id: ws_id, + public_id: workspace_public_id, + key: workspace_key, + name, + screens: vec![Screen { + id: screen_id, + public_id: ScreenPublicId::random()?, + name: None, + root: Node::Leaf(pane_id), + active_pane: pane_id, + zoomed_pane: None, + zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), + }], + active_screen: 0, + }); + state.active_workspace = state.workspaces.len() - 1; + state.workspace_revision = commit.revision; + let workspace_revision = commit.revision; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::WorkspaceAdded, + ws_id, + ) + .expect("new workspace is present in tree snapshot"); TreeDelta { kind: TreeDeltaKind::WorkspaceAdded, workspace: ws_id, @@ -10433,12947 +13739,14795 @@ impl Mux { surface: None, index: Some(index), entity, - workspace_revision: Some(revision), - }, - selection_resync, - ) + workspace_revision: Some(workspace_revision), + } + }; + let selection_resync = delta.index.is_some_and(|index| index > 0); + self.emit_committed_workspace_delta(®istry, delta, selection_resync); + drop(registry); + self.reap_if_dead(&surface); + return Ok(surface); }; - self.emit_committed_workspace_delta(®istry, delta, selection_resync); - drop(registry); - if project_resource { - self.publish_resource_event(); - } - Ok(placement) - } - - pub fn run_command_surface( - self: &Arc, - argv: Vec, - pane: Option, - new_workspace: bool, - cwd: Option, - name: Option, - size: Option<(u16, u16)>, - ) -> anyhow::Result { - self.run_command_surface_with_options( - argv, - RunCommandOptions { pane, new_workspace, workspace_key: None, cwd, name, size }, - ) - } - - /// Runs a command and optionally creates its workspace with a caller-owned - /// stable key. The key is only meaningful when `new_workspace` is true. - pub(crate) fn run_command_surface_with_options( - self: &Arc, - argv: Vec, - options: RunCommandOptions, - ) -> anyhow::Result { - self.run_command_result_with_options(argv, options)? - .placement - .context("command exited before its surface could be returned") - } - pub(crate) fn run_command_result_with_options( - self: &Arc, - argv: Vec, - options: RunCommandOptions, - ) -> anyhow::Result { - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let RunCommandOptions { pane, new_workspace, workspace_key, cwd, name, size } = options; - if workspace_key.is_some() && !new_workspace { - anyhow::bail!("workspace key requires a new workspace"); + let surface = + self.spawn_browser_surface_with_resource_identity(url, size, None, resource_identity)?; + #[cfg(test)] + if let Some(hook) = self.browser_tab_after_spawn.lock().unwrap().clone() { + hook(surface.clone()); } - let (operation, selectors) = if new_workspace { - (ResourceOperation::WorkspaceCreate, Self::ordinary_resource_selectors()) - } else { - let state = self.state.lock().unwrap(); - let target = match pane { - Some(id) => { - if !state.panes.contains_key(&id) { - anyhow::bail!("unknown pane {id}"); - } - Some(id) + let active_at = self.next_active_at(); + let notifications = self.surface_notifications(); + let (attached, retired) = { + let mut state = self.state.lock().unwrap(); + match state.panes.get_mut(&target) { + Some(pane) => { + pane.tabs.push(surface.id); + pane.active_tab = pane.tabs.len() - 1; + pane.active_at = active_at; + let index = pane.tabs.len() - 1; + fence_layout_undo_for_tab_membership(&mut state, &[target]); + let (wi, si) = state.screen_of(target).expect("live pane belongs to a screen"); + let workspace = state.workspaces[wi].id; + let screen = state.workspaces[wi].screens[si].id; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::TabAdded, + surface.id, + ) + .expect("new browser tab is present in tree snapshot"); + ( + Some(TreeDelta { + kind: TreeDeltaKind::TabAdded, + workspace, + screen: Some(screen), + pane: Some(target), + surface: Some(surface.id), + index: Some(index), + entity, + workspace_revision: None, + }), + None, + ) } - None => state.active_pane(), - }; - if let Some(target) = target { - drop(state); - ( - ResourceOperation::PaneRun, - self.ordinary_pane_selectors(target) - .with_context(|| format!("unknown pane {target}"))?, - ) - } else if let Some(workspace) = state.workspaces.get(state.active_workspace) { - let workspace = workspace.id; - drop(state); - ( - ResourceOperation::WorkspaceRun, - self.ordinary_workspace_selectors(workspace) - .with_context(|| format!("unknown workspace {workspace}"))?, - ) - } else { - (ResourceOperation::WorkspaceCreate, Self::ordinary_resource_selectors()) + None => (None, take_surface_for_retirement(self, &mut state, surface.id)), } }; - let mut fields = Map::from_iter([( - "argv".into(), - Value::Array(argv.into_iter().map(Value::String).collect()), - )]); - if operation == ResourceOperation::WorkspaceCreate { - fields.insert("initial_content".into(), Value::String("terminal".into())); - Self::insert_optional_string(&mut fields, "name", name.clone()); - Self::insert_optional_string(&mut fields, "workspace_key", workspace_key); - Self::insert_optional_string(&mut fields, "terminal_name", name); - } else { - Self::insert_optional_string(&mut fields, "name", name); - } - Self::insert_optional_string(&mut fields, "cwd", cwd); - Self::insert_cell_size(&mut fields, size); - let commit = self.commit_ordinary_topology_operation(operation, selectors, fields)?; - self.emit_resource_topology_legacy_events(operation, &commit); - let terminal_id = self.created_terminal_host_id(&commit.result)?; - let surface = self.ordinary_created_surface(&commit).ok(); - drop(_creation_handoff); - if let Some(surface) = surface.as_ref() { - self.reap_if_dead(surface); - } - self.created_terminal_run_result(&terminal_id) + let Some(delta) = attached else { + if let Some(retired) = retired { + self.retire_surface_runtime(retired); + } + anyhow::bail!("pane disappeared while creating browser tab"); + }; + self.emit_tree_delta(delta, true); + self.reap_if_dead(&surface); + Ok(surface) } - fn created_terminal_host_id(&self, created_path: &Value) -> anyhow::Result { - let terminal_id = TerminalPublicId::parse( - created_path["terminal_id"] - .as_str() - .context("created terminal result omitted its public terminal id")? - .to_string(), + fn create_browser_surface_in_workspace( + self: &Arc, + workspace: WorkspaceId, + url: String, + size: Option<(u16, u16)>, + resource_identity: Option, + ) -> anyhow::Result> { + let _creation = self.begin_surface_creation()?; + let lifecycle = self.workspace_lifecycle(workspace); + let workspace_lifecycle = lifecycle.lock().unwrap(); + if self.state.lock().unwrap().workspace_by_id(workspace).is_none() { + anyhow::bail!("unknown workspace {workspace}"); + } + let surface = self.spawn_browser_surface_with_resource_identity( + url, + size, + Some(workspace), + resource_identity, )?; - self.workspace_registry - .lock() - .unwrap() - .terminal_host_id(&terminal_id)? - .context("created terminal result has no durable host id") - } - - pub(crate) fn created_terminal_run_result( - &self, - terminal_id: &str, - ) -> anyhow::Result { - for _ in 0..2 { - let resolution = self - .resolve_terminal(terminal_id)? - .context("created terminal result has no durable terminal row")?; - let placement = resolution.surface.and_then(|surface| { - self.with_state(|state| run_placement_for_surface(state, surface)) - }); - match resolution.terminal.lifecycle { - TerminalLifecycle::Exited => { - anyhow::ensure!( - resolution.terminal.exit.is_some(), - "exited terminal omitted durable exit metadata" - ); - return Ok(RunCommandResult { - placement: None, - terminal: resolution.terminal, - terminal_revision: resolution.terminal_revision, + let pending_surface = self.pending_workspace_surface(surface.id); + let notifications = self.surface_notifications(); + let active_at = self.next_active_at(); + let (attachment, retired) = { + let mut state = self.state.lock().unwrap(); + let attachment = 'attach: { + let Some(wi) = state.workspace_index(workspace) else { + break 'attach Err("workspace disappeared while creating browser tab"); + }; + let target = + state.workspaces[wi].active_screen_ref().map(|screen| screen.active_pane); + if let Some(target) = target { + let Some((_, si)) = state.screen_of(target) else { + break 'attach Err( + "workspace active pane disappeared while creating browser tab", + ); + }; + let Some(pane) = state.panes.get_mut(&target) else { + break 'attach Err( + "workspace active pane disappeared while creating browser tab", + ); + }; + pane.tabs.push(surface.id); + pane.active_tab = pane.tabs.len() - 1; + pane.active_at = active_at; + let index = pane.tabs.len() - 1; + fence_layout_undo_for_tab_membership(&mut state, &[target]); + let screen = state.workspaces[wi].screens[si].id; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::TabAdded, + surface.id, + ) + .expect("new browser tab is present in tree snapshot"); + Ok(( + TreeDelta { + kind: TreeDeltaKind::TabAdded, + workspace, + screen: Some(screen), + pane: Some(target), + surface: Some(surface.id), + index: Some(index), + entity, + workspace_revision: None, + }, + true, + )) + } else { + let (pane_id, pane) = self.make_pane(surface.id)?; + let screen_id = self.next_id(); + state.insert_pane(pane); + stamp_pane_focus(self, &mut state, pane_id); + state.workspaces[wi].screens.push(Screen { + id: screen_id, + public_id: ScreenPublicId::random()?, + name: None, + root: Node::Leaf(pane_id), + active_pane: pane_id, + zoomed_pane: None, + zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }); + state.workspaces[wi].active_screen = 0; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::ScreenAdded, + screen_id, + ) + .expect("first browser screen is present in tree snapshot"); + Ok(( + TreeDelta { + kind: TreeDeltaKind::ScreenAdded, + workspace, + screen: Some(screen_id), + pane: None, + surface: None, + index: Some(0), + entity, + workspace_revision: None, + }, + false, + )) } - TerminalLifecycle::Running => { - if let Some(placement) = placement { - return Ok(RunCommandResult { - placement: Some(placement), - terminal: resolution.terminal, - terminal_revision: resolution.terminal_revision, - }); - } - // Exit commits lifecycle before installing the detached - // state. Re-read once if that transition landed between - // resolve_terminal's registry and state snapshots. - } - lifecycle => anyhow::bail!( - "created terminal is {} before its run result could be returned", - terminal_lifecycle_name(lifecycle) - ), - } + }; + let retired = attachment + .is_err() + .then(|| take_surface_for_retirement(self, &mut state, surface.id)) + .flatten(); + (attachment, retired) + }; + if let Some(retired) = retired { + self.retire_surface_runtime(retired); } - anyhow::bail!("created running terminal has no placement") + let (delta, selection_resync) = attachment.map_err(anyhow::Error::msg)?; + drop(pending_surface); + self.emit_tree_delta(delta, selection_resync); + drop(workspace_lifecycle); + self.reap_if_dead(&surface); + Ok(surface) } - pub(crate) fn reap_created_terminal_surface(self: &Arc, surface: Option) { - if let Some(surface) = surface.and_then(|surface| self.surface(surface)) { - self.reap_if_dead(&surface); + pub fn adopt_browser_target( + self: &Arc, + opener_surface: SurfaceId, + target_id: String, + url: String, + runtime: Arc, + ) -> anyhow::Result { + let _creation = self.begin_surface_creation()?; + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let (pane_id, size) = { + let state = self.state.lock().unwrap(); + let Some(pane_id) = state.pane_of(opener_surface) else { + return Ok(false); + }; + let size = state.surfaces.get(&opener_surface).map(|surface| surface.size()); + (pane_id, size) + }; + let id = self.next_id(); + let mut owner_reservation = self.reserve_surface_owner()?; + let opts = self.surface_options.lock().unwrap().clone(); + let size = size.unwrap_or((opts.cols, opts.rows)); + let cell_pixels = self.cell_pixel_creation_size(); + let surface = + browser::new_surface(id, url.clone(), size, cell_pixels, &opts, Arc::downgrade(self))?; + let active_at = self.next_active_at(); + let attached = match self.attach_browser_surface_to_pane_or_kill( + pane_id, + &surface, + active_at, + &mut owner_reservation, + ) { + BrowserSurfaceAttach::MissingPane | BrowserSurfaceAttach::Rejected => return Ok(false), + BrowserSurfaceAttach::Attached(delta) => delta, + }; + let identity = surface + .resource_identity() + .context("adopted browser surface omitted its public identity")?; + let result = serde_json::json!({ + "tab_id":identity.tab_id, + "browser_id":identity.content_id, + }); + if let Err(error) = + self.commit_ordinary_full_resource_projection("browser.target.adopt", result) + { + let rollback = self.close_surface_for_resource_effect(surface.id); + return match rollback { + Ok(true) => Err(error.context("could not persist adopted browser target")), + Ok(false) => Err(error.context( + "could not persist adopted browser target and its tab disappeared during rollback", + )), + Err(rollback) => Err(error.context(format!( + "could not persist adopted browser target; rollback also failed: {rollback:#}" + ))), + }; + } + if let Some(delta) = attached { + self.emit_tree_delta(delta, true); + } else { + self.emit(MuxEvent::TreeChanged); } + self.start_browser_bootstrap( + surface, + BrowserSurfaceBootstrap::ExistingTarget { runtime, target_id, url }, + ); + Ok(true) } - pub(crate) fn activate_created_terminal_surface( + fn attach_browser_surface_to_pane_or_kill( &self, - surface: Option, - ) -> anyhow::Result<()> { - if let Some(surface) = surface.and_then(|surface| self.surface(surface)) { - surface.activate_hosted_launch_stream()?; + pane_id: PaneId, + surface: &Arc, + active_at: u64, + owner_reservation: &mut SurfaceOwnerReservation<'_>, + ) -> BrowserSurfaceAttach { + let notifications = self.surface_notifications(); + let attached = { + let mut state = self.state.lock().unwrap(); + if !state.panes.contains_key(&pane_id) { + BrowserSurfaceAttach::MissingPane + } else if insert_reserved_surface_checked( + self, + &mut state, + surface.clone(), + owner_reservation, + ) + .is_err() + { + BrowserSurfaceAttach::Rejected + } else { + let pane = state.panes.get_mut(&pane_id).expect("pane existence was checked"); + pane.tabs.push(surface.id); + pane.active_tab = pane.tabs.len() - 1; + pane.active_at = active_at; + let delta = (|| { + let (wi, si) = state.screen_of(pane_id)?; + let pane = state.panes.get(&pane_id)?; + let index = pane.tabs.iter().position(|id| *id == surface.id)?; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::TabAdded, + surface.id, + )?; + Some(TreeDelta { + kind: TreeDeltaKind::TabAdded, + workspace: state.workspaces[wi].id, + screen: Some(state.workspaces[wi].screens[si].id), + pane: Some(pane_id), + surface: Some(surface.id), + index: Some(index), + entity, + workspace_revision: None, + }) + })(); + BrowserSurfaceAttach::Attached(delta) + } + }; + if matches!(attached, BrowserSurfaceAttach::MissingPane | BrowserSurfaceAttach::Rejected) { + self.retire_reserved_surface_runtime(surface.clone(), owner_reservation); } - Ok(()) + attached } - /// Create a screen in a workspace (default: the active one) with one - /// pane/tab, and make it active. Returns the tab's surface. - pub fn new_screen( - self: &Arc, - workspace: Option, - size: Option<(u16, u16)>, - ) -> anyhow::Result> { - self.new_screen_with_cwd(workspace, None, size) + /// Working directory of a pane's active surface, if reported. + fn pane_cwd(&self, pane: PaneId) -> Option { + let surface = { + let state = self.state.lock().unwrap(); + let active = state.panes.get(&pane)?.active_surface()?; + state.surfaces.get(&active).cloned() + }; + surface.and_then(|surface| surface.local_cwd()) } - fn new_screen_with_cwd( + fn workspace_key_for_pane(&self, pane: PaneId) -> Option { + let state = self.state.lock().unwrap(); + let (workspace, _) = state.screen_of(pane)?; + Some(state.workspaces[workspace].key.clone()) + } + + /// Split the screen containing `target`, putting a new single-tab + /// pane after it. Returns the new pane's surface. `size` is the + /// expected content size of the new pane, when the caller knows it. + pub fn split( self: &Arc, - workspace: Option, - cwd: Option, + target: PaneId, + dir: SplitDir, size: Option<(u16, u16)>, ) -> anyhow::Result> { + let _creation = self.begin_surface_creation()?; let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let selectors = match workspace { - Some(workspace) => self - .ordinary_workspace_selectors(workspace) - .with_context(|| format!("unknown workspace {workspace}"))?, - None => { - let active = self.with_state(|state| { - state.workspaces.get(state.active_workspace).map(|workspace| workspace.id) - }); - active - .and_then(|workspace| self.ordinary_workspace_selectors(workspace)) - .unwrap_or_else(Self::ordinary_resource_selectors) - } + let selectors = self + .ordinary_pane_selectors(target) + .with_context(|| format!("unknown pane {target}"))?; + let direction = match dir { + SplitDir::Right => "right", + SplitDir::Down => "down", }; - let mut fields = Map::new(); - Self::insert_optional_string(&mut fields, "cwd", cwd); + let mut fields = Map::from_iter([("direction".into(), Value::String(direction.into()))]); Self::insert_cell_size(&mut fields, size); let commit = self.commit_ordinary_topology_operation( - ResourceOperation::ScreenCreate, + ResourceOperation::PaneSplit, selectors, fields, )?; - self.emit_resource_topology_legacy_events(ResourceOperation::ScreenCreate, &commit); + self.emit_resource_topology_legacy_events(ResourceOperation::PaneSplit, &commit); + self.ordinary_created_surface(&commit) + } + + /// Add a terminal as a viewport-width column after the target's column. + /// + /// Updated frontends render the new column at `width` times their own + /// viewport width. The ordinary split ratio remains valid fallback data + /// for older clients. + pub fn new_pane_right( + self: &Arc, + target: PaneId, + width: f32, + size: Option<(u16, u16)>, + ) -> anyhow::Result> { + let _creation = self.begin_surface_creation()?; + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + if !width.is_finite() + || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) + { + return Err(ViewportWidthError::OutOfRange { width }.into()); + } + let selectors = self + .ordinary_pane_selectors(target) + .with_context(|| format!("unknown pane {target}"))?; + let mut fields = Map::from_iter([ + ("direction".into(), Value::String("right".into())), + ("viewport_width".into(), Value::from(width)), + ]); + Self::insert_cell_size(&mut fields, size); + let commit = self + .commit_ordinary_topology_operation(ResourceOperation::PaneSplit, selectors, fields) + .map_err(|error| { + eprintln!("cmux-tui: viewport pane PTY creation failed: {error:#}"); + anyhow::anyhow!("pane creation failed") + })?; + self.emit_resource_topology_legacy_events(ResourceOperation::PaneSplit, &commit); self.ordinary_created_surface(&commit) } - /// Create a tab in a pane (default: the active pane of the active - /// screen). When the session has no workspaces yet (headless before - /// any command), a workspace is created around the new tab. - pub fn new_tab( + /// Create a pane and reapply Zellij's default pane distribution to the + /// containing screen. The screen stores creation order independently of + /// the mutable split tree, so swaps and directional splits cannot reorder + /// terminals when automatic layout resumes. + pub fn new_pane( self: &Arc, - pane: Option, - cwd: Option, + target: PaneId, size: Option<(u16, u16)>, ) -> anyhow::Result> { + let _creation = self.begin_surface_creation()?; let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let selectors = { - let state = self.state.lock().unwrap(); - let target = match pane { - Some(id) => { - if !state.panes.contains_key(&id) { - anyhow::bail!("unknown pane {id}"); - } - Some(id) - } - None => state.active_pane(), - }; - if let Some(target) = target { - drop(state); - self.ordinary_pane_selectors(target) - .with_context(|| format!("unknown pane {target}"))? - } else if let Some(workspace) = state.workspaces.get(state.active_workspace) { - let workspace = workspace.id; - drop(state); - self.ordinary_workspace_selectors(workspace) - .with_context(|| format!("unknown workspace {workspace}"))? - } else { - Self::ordinary_resource_selectors() - } - }; + let selectors = self + .ordinary_pane_selectors(target) + .with_context(|| format!("unknown pane {target}"))?; let mut fields = Map::new(); - Self::insert_optional_string(&mut fields, "cwd", cwd); Self::insert_cell_size(&mut fields, size); let commit = self.commit_ordinary_topology_operation( - ResourceOperation::TabCreateTerminal, + ResourceOperation::PaneCreate, selectors, fields, )?; - self.emit_resource_topology_legacy_events(ResourceOperation::TabCreateTerminal, &commit); + self.emit_resource_topology_legacy_events(ResourceOperation::PaneCreate, &commit); self.ordinary_created_surface(&commit) } - /// Create a terminal in a specific workspace without changing the mux's - /// active workspace. An empty workspace gets its first screen and pane; - /// otherwise the new surface becomes a tab in that workspace's active - /// pane. The target is re-resolved under the attach lock so concurrent - /// first-terminal requests cannot accidentally create another workspace. - pub fn create_terminal_in_workspace( - self: &Arc, - workspace: WorkspaceId, - argv: Option>, - cwd: Option, - name: Option, - size: Option<(u16, u16)>, - ) -> anyhow::Result { - self.create_terminal_surface_in_workspace(workspace, argv, cwd, name, size) - .map(|(_, placement)| placement) + /// Close one tab. When it was the pane's last tab, the pane collapses + /// out of its split tree. Empty workspace containers remain durable; + /// only an explicit close-workspace mutation removes a workspace. + pub fn close_surface(self: &Arc, target: SurfaceId) -> anyhow::Result { + let Some(selectors) = self.ordinary_tab_selectors(target) else { return Ok(false) }; + let commit = self + .commit_ordinary_topology_operation(ResourceOperation::TabClose, selectors, Map::new()) + .with_context(|| format!("close surface {target}"))?; + self.emit_resource_topology_legacy_events(ResourceOperation::TabClose, &commit); + Ok(true) + } + + pub(crate) fn close_surface_for_resource_effect( + &self, + target: SurfaceId, + ) -> anyhow::Result { + Ok(self.remove_surface_after_registry(target)) + } + + fn remove_surface_after_registry(&self, target: SurfaceId) -> bool { + let notifications = self.surface_notifications(); + let remove = || { + let mut state = self.state.lock().unwrap(); + let selection_before = active_tree_selection(&state); + let changed_screen = surface_screen_id(&state, target); + let delta = close_surface_delta(&state, ¬ifications, target); + let (removed, split_index_dirty) = remove_surface(self, &mut state, target); + if split_index_dirty { + Self::rebuild_split_screen_index(&mut state); + } + let empty_revision = state.workspaces.is_empty().then_some(state.workspace_revision); + let selection_resync = + empty_revision.is_none() && selection_before != active_tree_selection(&state); + let changed = removed.is_some() || delta.is_some(); + ( + removed, + changed_screen.into_iter().collect::>(), + empty_revision, + delta, + selection_resync, + changed, + ) + }; + let (removed, changed_screens, empty_revision, delta, selection_resync, changed) = loop { + let Some(workspace) = self.surface_workspace(target) else { + break remove(); + }; + let lifecycle = self.workspace_lifecycle(workspace); + let workspace_lifecycle = lifecycle.lock().unwrap(); + if self.surface_workspace(target) != Some(workspace) { + drop(workspace_lifecycle); + continue; + } + let result = remove(); + drop(workspace_lifecycle); + break result; + }; + if let Some(surface) = &removed { + self.purge_surface_side_tables(surface.id); + if surface.kind() == SurfaceKind::Browser { + surface.kill(); + } + } + if let Some(delta) = delta { + self.emit_tree_delta(delta, selection_resync); + } else if removed.is_some() { + self.emit(MuxEvent::TreeChanged); + } + if removed.is_some() || !changed_screens.is_empty() { + for screen in changed_screens { + self.emit(MuxEvent::LayoutChanged(screen)); + } + } + self.emit_empty_if_current(empty_revision); + changed + } + + /// Close a pane or screen while holding the target workspace's lifecycle + /// lock. Tabs are detached view items; terminal content remains in the + /// catalog until an explicit terminal close. + fn close_tree_target(&self, target: TreeCloseTarget) -> anyhow::Result { + let notifications = self.surface_notifications(); + let result = loop { + let Some(workspace) = + self.with_state(|state| Self::workspace_for_tree_target_in_state(state, target)) + else { + return Ok(false); + }; + let lifecycle = self.workspace_lifecycle(workspace); + let workspace_lifecycle = lifecycle.lock().unwrap(); + if self.with_state(|state| Self::workspace_for_tree_target_in_state(state, target)) + != Some(workspace) + { + drop(workspace_lifecycle); + continue; + } + let result = (|| -> anyhow::Result> { + let mut state = self.state.lock().unwrap(); + let selection_before = active_tree_selection(&state); + let (tabs, delta) = match target { + TreeCloseTarget::Pane(target) => { + let Some(pane) = state.panes.get(&target) else { return Ok(None) }; + ( + pane.tabs.clone(), + close_pane_delta(&state, ¬ifications, target) + .expect("live pane has a close delta"), + ) + } + TreeCloseTarget::Screen(target) => { + let Some(screen) = state + .workspaces + .iter() + .flat_map(|workspace| &workspace.screens) + .find(|screen| screen.id == target) + else { + return Ok(None); + }; + ( + screen_tabs(&state, screen), + close_screen_delta(&state, ¬ifications, target) + .expect("live screen has a close delta"), + ) + } + }; + let changed_screens = unique_screen_ids( + tabs.iter().filter_map(|surface| surface_screen_id(&state, *surface)), + ); + let mut removed = Vec::new(); + let mut split_index_dirty = false; + for surface in tabs { + let (surface, topology_changed) = remove_surface(self, &mut state, surface); + split_index_dirty |= topology_changed; + if let Some(surface) = surface { + removed.push(surface); + } + } + if split_index_dirty { + Self::rebuild_split_screen_index(&mut state); + } + let tree_removed = match target { + TreeCloseTarget::Pane(target) => !state.panes.contains_key(&target), + TreeCloseTarget::Screen(target) => !state + .workspaces + .iter() + .flat_map(|workspace| &workspace.screens) + .any(|screen| screen.id == target), + }; + let empty_revision = + state.workspaces.is_empty().then_some(state.workspace_revision); + let selection_resync = + empty_revision.is_none() && selection_before != active_tree_selection(&state); + Ok(Some(( + removed, + changed_screens, + empty_revision, + delta, + tree_removed, + selection_resync, + ))) + })(); + let result = result?; + drop(workspace_lifecycle); + break result; + }; + let Some((removed, changed_screens, empty_revision, delta, tree_removed, selection_resync)) = + result + else { + return Ok(false); + }; + for surface in removed { + self.purge_surface_side_tables(surface.id); + if surface.kind() == SurfaceKind::Browser { + surface.kill(); + } + } + if tree_removed { + self.emit_tree_delta(delta, selection_resync); + for screen in changed_screens { + self.emit(MuxEvent::LayoutChanged(screen)); + } + } + self.emit_empty_if_current(empty_revision); + Ok(true) + } + + /// Close a pane and every tab in it. + pub fn close_pane(self: &Arc, target: PaneId) -> anyhow::Result { + let Some(selectors) = self.ordinary_pane_selectors(target) else { return Ok(false) }; + let commit = self + .commit_ordinary_topology_operation(ResourceOperation::PaneClose, selectors, Map::new()) + .with_context(|| format!("close pane {target}"))?; + self.emit_resource_topology_legacy_events(ResourceOperation::PaneClose, &commit); + Ok(true) + } + + pub(crate) fn close_pane_for_resource_effect(&self, target: PaneId) -> anyhow::Result { + self.close_tree_target(TreeCloseTarget::Pane(target)) + .with_context(|| format!("close pane {target}")) + } + + /// Close a screen and every pane/tab in it. + pub fn close_screen(self: &Arc, target: ScreenId) -> anyhow::Result { + let Some(selectors) = self.ordinary_screen_selectors(target) else { return Ok(false) }; + let commit = self + .commit_ordinary_topology_operation( + ResourceOperation::ScreenClose, + selectors, + Map::new(), + ) + .with_context(|| format!("close screen {target}"))?; + self.emit_resource_topology_legacy_events(ResourceOperation::ScreenClose, &commit); + Ok(true) + } + + pub(crate) fn close_screen_for_resource_effect( + &self, + target: ScreenId, + ) -> anyhow::Result { + self.close_tree_target(TreeCloseTarget::Screen(target)) + .with_context(|| format!("close screen {target}")) } - pub(crate) fn create_terminal_result_in_workspace( - self: &Arc, - workspace: WorkspaceId, - argv: Option>, - cwd: Option, - name: Option, - size: Option<(u16, u16)>, - ) -> anyhow::Result { - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let selectors = self - .ordinary_workspace_selectors(workspace) - .with_context(|| format!("unknown workspace {workspace}"))?; - let operation = if argv.is_some() { - ResourceOperation::WorkspaceRun - } else { - ResourceOperation::TabCreateTerminal - }; - let mut fields = Map::new(); - if let Some(argv) = argv { - fields - .insert("argv".into(), Value::Array(argv.into_iter().map(Value::String).collect())); - } - Self::insert_optional_string(&mut fields, "cwd", cwd); - Self::insert_optional_string(&mut fields, "name", name); - Self::insert_cell_size(&mut fields, size); - let commit = self.commit_ordinary_topology_operation(operation, selectors, fields)?; - self.emit_resource_topology_legacy_events(operation, &commit); - let terminal_id = self.created_terminal_host_id(&commit.result)?; - let surface = self.ordinary_created_surface(&commit).ok(); - drop(_creation_handoff); - if let Some(surface) = surface.as_ref() { - self.reap_if_dead(surface); - } - self.created_terminal_run_result(&terminal_id) + /// Close a workspace and every screen/pane/tab in it. + pub fn close_workspace(&self, target: WorkspaceId) -> bool { + self.close_workspace_at_revision(target, None) + .map(|revision| revision.is_some()) + .unwrap_or(false) } - fn create_terminal_surface_in_workspace( - self: &Arc, - workspace: WorkspaceId, - argv: Option>, - cwd: Option, - name: Option, - size: Option<(u16, u16)>, - ) -> anyhow::Result<(Arc, RunPlacement)> { - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let selectors = self - .ordinary_workspace_selectors(workspace) - .with_context(|| format!("unknown workspace {workspace}"))?; - let operation = if argv.is_some() { - ResourceOperation::WorkspaceRun - } else { - ResourceOperation::TabCreateTerminal - }; - let mut fields = Map::new(); - if let Some(argv) = argv { - fields - .insert("argv".into(), Value::Array(argv.into_iter().map(Value::String).collect())); - } - Self::insert_optional_string(&mut fields, "cwd", cwd); - Self::insert_optional_string(&mut fields, "name", name); - Self::insert_cell_size(&mut fields, size); - let commit = self.commit_ordinary_topology_operation(operation, selectors, fields)?; - self.emit_resource_topology_legacy_events(operation, &commit); - let surface = self.ordinary_created_surface(&commit)?; - let placement = self - .with_state(|state| run_placement_for_surface(state, surface.id)) - .context("created terminal has no placement")?; - Ok((surface, placement)) + /// Atomically close one workspace if the caller's registry snapshot is + /// still current. Returns the resulting revision when the workspace was + /// present and closed. + pub fn close_workspace_at_revision( + &self, + target: WorkspaceId, + expected_revision: Option, + ) -> anyhow::Result> { + Ok(self + .close_workspace_selector_at_revision(Some(target), None, expected_revision)? + .map(|(_, _, revision)| revision)) } - #[allow(clippy::too_many_arguments)] - pub(crate) fn create_raw_terminal_in_workspace_with_mutation( - self: &Arc, - workspace: WorkspaceId, - argv: Option>, - cwd: Option, - name: Option, - size: Option<(u16, u16)>, - requested_terminal_id: Option<&str>, - expected_generation: Option<&str>, + pub(crate) fn close_workspace_selector_at_revision( + &self, + id: Option, + key: Option<&str>, expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let _creation_execution = self.resource_creation_execution.lock().unwrap(); - self.create_terminal_in_workspace_with_mutation( - workspace, - argv, - cwd, - name, - size, - requested_terminal_id, - expected_generation, + ) -> anyhow::Result> { + self.close_workspace_selector_with_authority( + id, + key, expected_revision, - mutation, + WorkspaceMutationAuthority::Ordinary, + true, ) } - #[allow(clippy::too_many_arguments)] - pub fn create_terminal_in_workspace_with_mutation( - self: &Arc, - workspace: WorkspaceId, - argv: Option>, - cwd: Option, - name: Option, - size: Option<(u16, u16)>, - requested_terminal_id: Option<&str>, + pub(crate) fn close_workspace_at_revision_for_resource_effect( + &self, + target: WorkspaceId, + ) -> anyhow::Result> { + Ok(self + .close_workspace_selector_with_authority( + Some(target), + None, + None, + WorkspaceMutationAuthority::Ordinary, + false, + )? + .map(|(_, _, revision)| revision)) + } + + pub fn close_workspace_with_mutation( + &self, + target: Option, + requested_key: Option<&str>, expected_generation: Option<&str>, expected_revision: Option, mutation: &WorkspaceMutation, - ) -> anyhow::Result { - let workspace_key = self - .state - .lock() - .unwrap() - .workspace_by_id(workspace) - .map(|workspace| workspace.key.clone()) - .ok_or_else(|| anyhow::anyhow!("unknown workspace {workspace}"))?; - if let Some(terminal_id) = requested_terminal_id { - validate_terminal_hex(terminal_id, "invalid_terminal_id")?; - } - let fingerprint = terminal_create_fingerprint( - &workspace_key, - requested_terminal_id, - argv.as_deref(), - cwd.as_deref(), - name.as_deref(), - size, + ) -> anyhow::Result { + let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); + let _creation_fence = self.resource_creation_execution.lock().unwrap(); + let authority = self.authorize_workspace_lifecycle_mutation( + WorkspaceMutationAuthority::Ordinary, + "close", )?; - let replay = - { self.workspace_registry.lock().unwrap().replay_terminal(mutation, &fingerprint)? }; - if let Some(replay) = replay { - let terminal_id = replay.result["terminal_id"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("stored terminal create result is missing id"))?; - return self.replayed_terminal_placement(terminal_id); - } - let terminal_id = match requested_terminal_id { - Some(value) => TerminalId::from_hex(value).expect("validated terminal UUID"), - None => TerminalId::random()?, - }; - let reservation = TerminalReservationRequest { - terminal_id, - mutation: mutation.clone(), - fingerprint, - expected_generation: expected_generation.map(str::to_string), + let result = self.close_workspace_with_mutation_inner( + target, + requested_key, + expected_generation, expected_revision, - }; - let (placement, surface, created_path) = self.create_terminal_in_workspace_impl( - workspace, - argv, - cwd, - name, - size, - Some(reservation), - )?; - let identity = self - .resource_terminal_host_identity(&surface) - .ok_or_else(|| anyhow::anyhow!("created terminal has no host identity"))?; - let snapshot = self.workspace_registry.lock().unwrap().terminal_snapshot()?; - Ok(TerminalPlacementResult { - placement: Some(placement), - terminal_id: identity.terminal_id, - terminal_incarnation: Some(identity.incarnation), - terminal_revision: snapshot.revision, - replayed: false, - created_path: Some(created_path), - created_surface: Some(surface.id), - }) + mutation, + true, + ); + drop(authority); + result + } + + pub fn close_provider_managed_workspace( + &self, + id: WorkspaceId, + key: &str, + ) -> anyhow::Result> { + Ok(self + .close_workspace_selector_with_authority( + Some(id), + Some(key), + None, + WorkspaceMutationAuthority::TrustedProvider, + true, + )? + .map(|(_, _, revision)| revision)) } - fn replayed_terminal_placement( + pub(crate) fn close_provider_managed_workspace_authorized( &self, - terminal_id: &str, - ) -> anyhow::Result { - let resolved = self.created_terminal_run_result(terminal_id)?; - let placement = resolved.placement; - let surface = placement.map(|placement| placement.surface); - let created_path = - surface.map(|surface| self.created_resource_path(surface)).transpose()?; - Ok(TerminalPlacementResult { - placement, - terminal_id: resolved.terminal.terminal_id, - terminal_incarnation: resolved.terminal.incarnation, - terminal_revision: resolved.terminal_revision, - replayed: true, - created_path, - created_surface: surface, - }) + id: WorkspaceId, + key: &str, + authority: &str, + ) -> anyhow::Result> { + Ok(self + .close_workspace_selector_with_authority( + Some(id), + Some(key), + None, + WorkspaceMutationAuthority::ProviderCredential(authority), + true, + )? + .map(|(_, _, revision)| revision)) } - fn create_terminal_in_workspace_impl( - self: &Arc, - workspace: WorkspaceId, - argv: Option>, - cwd: Option, - name: Option, - size: Option<(u16, u16)>, - reservation: Option, - ) -> anyhow::Result<(RunPlacement, Arc, Value)> { + fn close_workspace_selector_with_authority( + &self, + id: Option, + key: Option<&str>, + expected_revision: Option, + authorization: WorkspaceMutationAuthority<'_>, + project_resource: bool, + ) -> anyhow::Result> { + let _creation_handoff = + project_resource.then(|| self.resource_creation_handoff.lock().unwrap()); + let _creation_fence = + project_resource.then(|| self.resource_creation_execution.lock().unwrap()); + let authority = self.authorize_workspace_lifecycle_mutation(authorization, "close")?; + let mutation = local_workspace_close_mutation(id, key, project_resource); + let fingerprint = workspace_close_fingerprint(id, key); { - let state = self.state.lock().unwrap(); - if state.workspace_by_id(workspace).is_none() { - anyhow::bail!("unknown workspace {workspace}"); + let registry = self.workspace_registry.lock().unwrap(); + if let Some(commit) = registry.replay(&mutation, &fingerprint)? { + let result = workspace_mutation_result(&commit)?; + let workspace = result + .workspace + .context("stored local workspace close result is missing its workspace")?; + drop(registry); + self.terminate_workspace_hosts_from_receipt(&commit.result)?; + drop(authority); + return Ok(Some((workspace, result.key, result.revision))); } } - #[cfg(test)] - if let Some(hook) = self.terminal_create_after_empty_check.lock().unwrap().clone() { - hook(); - } - let lifecycle = self.workspace_lifecycle(workspace); - let workspace_lifecycle = lifecycle.lock().unwrap(); - #[cfg(test)] - if let Some(hook) = self.terminal_create_after_materialization_lock.lock().unwrap().clone() - { - hook(); - } - #[cfg(test)] - if let Some(hook) = self.terminal_create_after_workspace_reservation.lock().unwrap().clone() - { - hook(); - } - let (workspace_key, inherited_pane) = { + let resolved = { let state = self.state.lock().unwrap(); - let Some(workspace) = state.workspace_by_id(workspace) else { - anyhow::bail!("unknown workspace {workspace}"); - }; - (workspace.key.clone(), workspace.active_screen_ref().map(|screen| screen.active_pane)) - }; - let inherited_cwd = inherited_pane.and_then(|pane| self.pane_cwd(pane)); - let surface = match reservation { - Some(reservation) => self.spawn_surface_in_workspace_reserved( - &workspace_key, - cwd.or(inherited_cwd), - size, - argv, - reservation, - )?, - None => { - self.spawn_surface_in_workspace(&workspace_key, cwd.or(inherited_cwd), size, argv)? - } - }; - self.pending_workspace_surfaces.lock().unwrap().insert(surface.id, workspace); - let pending_surface = self.pending_workspace_surface(surface.id); - if let Some(name) = name { - surface.set_name(Some(name)); - } - if surface.terminal_host_identity().is_some() { - // Launch/Ready intentionally releases the registry lock around - // process startup. Re-read canonical placement after Ready and - // hold registry -> state through the binding so a move committed - // during launch is projected instead of the stale request target. - let projected = self.bind_running_terminal_to_canonical_workspace(&surface); - let (placement, canonical_workspace, changed, created_path) = match projected { - Ok(projected) => projected, - Err(error) => { - self.fail_hosted_terminal_attachment( - &surface, - "terminal-topology-attach-failed", - "topology-attach-failed", - )?; - return Err(error); - } - }; - let _ = surface.persist_host_workspace(&canonical_workspace); - if changed { - self.emit(MuxEvent::TreeChanged); - } - drop(pending_surface); - drop(workspace_lifecycle); - return Ok((placement, surface, created_path)); - } - let notifications = self.surface_notifications(); - let active_at = self.next_active_at(); - let mut rollback_removed = Vec::new(); - let attached = { - let mut state = self.state.lock().unwrap(); - let result = (|| -> anyhow::Result<_> { - anyhow::ensure!( - state.surfaces.contains_key(&surface.id), - "terminal closed while its topology binding was being created" - ); - let wi = state - .workspace_index(workspace) - .context("workspace disappeared while creating terminal")?; - let target = - state.workspaces[wi].active_screen_ref().map(|screen| screen.active_pane); - if let Some(target) = target { - let (_, si) = state - .screen_of(target) - .context("workspace active pane disappeared while creating terminal")?; - let pane = state - .panes - .get_mut(&target) - .context("workspace active pane disappeared while creating terminal")?; - pane.tabs.push(surface.id); - pane.active_tab = pane.tabs.len() - 1; - pane.active_at = active_at; - let index = pane.tabs.len() - 1; - fence_layout_undo_for_tab_membership(&mut state, &[target]); - let screen = state.workspaces[wi].screens[si].id; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::TabAdded, - surface.id, - ) - .expect("new terminal tab is present in tree snapshot"); - let placement = - RunPlacement { surface: surface.id, pane: target, screen, workspace }; - let created_path = self.created_resource_path_in_state(&state, surface.id)?; - Ok(( - placement, - TreeDelta { - kind: TreeDeltaKind::TabAdded, - workspace, - screen: Some(screen), - pane: Some(target), - surface: Some(surface.id), - index: Some(index), - entity, - workspace_revision: None, - }, - true, - created_path, - )) - } else { - let (pane_id, pane) = self.make_pane(surface.id)?; - let screen_id = self.next_id(); - let screen_public_id = ScreenPublicId::random()?; - state.insert_pane(pane); - stamp_pane_focus(self, &mut state, pane_id); - state.workspaces[wi].screens.push(Screen { - id: screen_id, - public_id: screen_public_id, - name: None, - root: Node::Leaf(pane_id), - active_pane: pane_id, - zoomed_pane: None, - zellij_auto_layout: Some(vec![pane_id]), - viewport_splits: Default::default(), - viewport_base_width: None, - layout_columns: Vec::new(), - layout_revision: 0, - layout_undo: Default::default(), - }); - state.workspaces[wi].active_screen = 0; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::ScreenAdded, - screen_id, - ) - .expect("first workspace screen is present in tree snapshot"); - let placement = RunPlacement { - surface: surface.id, - pane: pane_id, - screen: screen_id, - workspace, - }; - let created_path = self.created_resource_path_in_state(&state, surface.id)?; - Ok(( - placement, - TreeDelta { - kind: TreeDeltaKind::ScreenAdded, - workspace, - screen: Some(screen_id), - pane: None, - surface: None, - index: Some(0), - entity, - workspace_revision: None, - }, - false, - created_path, - )) - } - })(); - if result.is_err() { - rollback_removed = remove_terminal_runtime_from_state(self, &mut state, &surface).0; - } - result + Self::require_workspace_revision(&state, expected_revision)?; + Self::resolve_workspace_selector(&state, id, key)? }; - let attached = match attached { - Ok(attached) => attached, - Err(error) => { - drop(pending_surface); - for placement in rollback_removed { - self.purge_surface_side_tables(placement.id); - } - self.purge_terminal_runtime_side_tables(&surface); - if !surface.is_dead() { - surface.kill(); - } - return Err(error); - } + let Some((resolved_target, _)) = resolved else { + return Ok(None); }; - drop(pending_surface); - self.emit_tree_delta(attached.1, attached.2); - drop(workspace_lifecycle); - Ok((attached.0, surface, attached.3)) + let result = self.close_workspace_with_mutation_inner( + id, + key, + None, + expected_revision, + &mutation, + project_resource, + ); + drop(authority); + let result = result?; + Ok(Some((result.workspace.unwrap_or(resolved_target), result.key, result.revision))) } - /// Bind a just-launched hosted surface using the latest durable row, not - /// the workspace requested before process launch. Holding registry -> - /// state through projection is the create/move serialization fence. - fn bind_running_terminal_to_canonical_workspace( + fn close_workspace_with_mutation_inner( &self, - surface: &Arc, - ) -> anyhow::Result<(RunPlacement, String, bool, Value)> { - let identity = surface - .terminal_host_identity() - .ok_or_else(|| anyhow::anyhow!("created terminal has no host identity"))?; - let registry = self.workspace_registry.lock().unwrap(); - let terminal = registry - .terminal_record(&identity.terminal_id)? - .ok_or_else(|| anyhow::anyhow!("created terminal has no registry row"))?; - if terminal.lifecycle != TerminalLifecycle::Running { - anyhow::bail!( - "created terminal is {} before topology binding", - terminal_lifecycle_name(terminal.lifecycle) - ); + target: Option, + requested_key: Option<&str>, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + project_resource: bool, + ) -> anyhow::Result { + let fingerprint = workspace_close_fingerprint(target, requested_key); + { + let registry = self.workspace_registry.lock().unwrap(); + if let Some(commit) = registry.replay(mutation, &fingerprint)? { + let result = workspace_mutation_result(&commit)?; + drop(registry); + self.terminate_workspace_hosts_from_receipt(&commit.result)?; + return Ok(result); + } } - let mut state = self.state.lock().unwrap(); - if !state.surfaces.contains_key(&surface.id) { - anyhow::bail!("terminal closed while its topology binding was being created"); + loop { + let resolved_target = { + let state = self.state.lock().unwrap(); + Self::require_workspace_revision(&state, expected_revision)?; + let index = resolve_workspace_index(&state, target, requested_key)?; + state.workspaces[index].id + }; + #[cfg(test)] + if let Some(hook) = + self.workspace_close_after_selector_resolution.lock().unwrap().clone() + { + hook(); + } + let lifecycle = self.workspace_lifecycle(resolved_target); + let workspace_lifecycle = lifecycle.lock().unwrap(); + let current_target = { + let state = self.state.lock().unwrap(); + Self::require_workspace_revision(&state, expected_revision)?; + let index = resolve_workspace_index(&state, target, requested_key)?; + state.workspaces[index].id + }; + if current_target != resolved_target { + drop(workspace_lifecycle); + continue; + } + let result = self.close_workspace_with_mutation_locked( + target, + requested_key, + expected_generation, + expected_revision, + mutation, + &fingerprint, + resolved_target, + project_resource, + ); + drop(workspace_lifecycle); + return result; } - let (placement, changed) = self.project_terminal_to_workspace_in_state( - &mut state, - &identity.terminal_id, - &terminal.workspace_key, - )?; - let placement = - placement.ok_or_else(|| anyhow::anyhow!("created terminal has no live surface"))?; - let created_path = self.created_resource_path_in_state(&state, surface.id)?; - Ok((placement, terminal.workspace_key, changed, created_path)) } - /// Create a browser tab in a pane (default: the active pane). When - /// the session has no workspaces yet, a workspace is created around - /// the browser tab. - pub fn new_browser_tab( - self: &Arc, - url: String, - pane: Option, - size: Option<(u16, u16)>, - ) -> anyhow::Result> { - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let selectors = { - let state = self.state.lock().unwrap(); - let target = match pane { - Some(id) => { - if !state.panes.contains_key(&id) { - anyhow::bail!("unknown pane {id}"); - } - Some(id) - } - None => state.active_pane(), + #[allow(clippy::too_many_arguments)] + fn close_workspace_with_mutation_locked( + &self, + target: Option, + requested_key: Option<&str>, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + fingerprint: &Value, + resolved_target: WorkspaceId, + project_resource: bool, + ) -> anyhow::Result { + let notifications = self.surface_notifications(); + let terminal_host_root = self.surface_options.lock().unwrap().terminal_host_root.clone(); + let mut registry = self.workspace_registry.lock().unwrap(); + if let Some(commit) = registry.replay(mutation, fingerprint)? { + let result = workspace_mutation_result(&commit)?; + drop(registry); + self.terminate_workspace_hosts_from_receipt(&commit.result)?; + return Ok(result); + } + let terminal_revision_before = registry.terminal_snapshot()?.revision; + let ( + removed, + delta, + empty_revision, + selection_resync, + result, + closed_public_ids, + terminal_runtimes, + host_cleanup, + ) = { + let mut state = self.state.lock().unwrap(); + Self::require_workspace_revision(&state, expected_revision)?; + let index = resolve_workspace_index(&state, target, requested_key)?; + let workspace_id = state.workspaces[index].id; + if workspace_id != resolved_target { + anyhow::bail!("workspace selector changed while closing"); + } + let previous_active = state.active_pane(); + let key = state.workspaces[index].key.clone(); + let host_identities = registry.terminal_host_identities_in_workspace(&key)?; + let closed_public_ids = + Self::terminal_public_ids_for_hosted(®istry, &host_identities)?; + let host_cleanup = self.prepare_terminal_host_cleanup_at_root( + terminal_host_root.as_deref(), + &host_identities, + )?; + let mut desired = self.registry_projection(&state); + desired.remove(index); + let desired_active_workspace = if state.active_workspace == index { + desired.last().map(|workspace| &workspace.public_id) + } else { + state.workspaces.get(state.active_workspace).map(|workspace| &workspace.public_id) }; - if let Some(target) = target { - drop(state); - self.ordinary_pane_selectors(target) - .with_context(|| format!("unknown pane {target}"))? - } else if let Some(workspace) = state.workspaces.get(state.active_workspace) { - let workspace = workspace.id; - drop(state); - self.ordinary_workspace_selectors(workspace) - .with_context(|| format!("unknown workspace {workspace}"))? + let committed_result = serde_json::json!({ + "workspace": workspace_id, + "key": key, + "index": index, + "changed": true, + (TERMINAL_HOST_CLEANUP_RECEIPT_FIELD): + terminal_host_cleanup_receipt(&host_identities), + }); + let commit = if project_resource { + registry.commit_with_active_workspace( + mutation, + fingerprint, + expected_generation, + expected_revision, + "workspace-closed", + &key, + &desired, + desired_active_workspace, + &committed_result, + )? } else { - Self::ordinary_resource_selectors() + registry.commit_for_resource_effect( + mutation, + fingerprint, + expected_generation, + expected_revision, + "workspace-closed", + &key, + &desired, + desired_active_workspace, + &committed_result, + )? + }; + let resource_revision = project_resource + .then(|| registry.snapshot().map(|snapshot| snapshot.resource_revision)) + .transpose()?; + let mut delta = close_workspace_delta(&state, ¬ifications, workspace_id) + .expect("live workspace has a close delta"); + let was_active = state.active_workspace == index; + let active_id = + state.workspaces.get(state.active_workspace).map(|workspace| workspace.id); + let workspace = state.remove_workspace(index); + let mut pane_ids = Vec::new(); + for screen in &workspace.screens { + screen.root.pane_ids(&mut pane_ids); + } + let mut removed = Vec::new(); + for pane_id in pane_ids { + if let Some(pane) = state.remove_pane(pane_id) { + for surface in pane.tabs { + if let Some(surface) = state.surfaces.remove(&surface) { + removed.push(surface); + } + } + } + } + let terminal_runtimes = closed_public_ids + .iter() + .filter_map(|terminal_id| state.terminal_catalog.get(terminal_id).cloned()) + .collect::>(); + for runtime in &terminal_runtimes { + removed.extend(remove_terminal_runtime_from_state(self, &mut state, runtime).0); + } + state.active_workspace = active_id + .and_then(|id| state.workspace_index(id)) + .unwrap_or_else(|| state.workspaces.len().saturating_sub(1)); + stamp_changed_active_pane(self, &mut state, previous_active); + Self::rebuild_split_screen_index(&mut state); + state.workspace_revision = commit.revision; + if let Some(resource_revision) = resource_revision { + state.resource_revision = resource_revision; } + delta.workspace_revision = Some(commit.revision); + let empty_revision = state.workspaces.is_empty().then_some(state.workspace_revision); + let selection_resync = was_active && empty_revision.is_none(); + let result = workspace_mutation_result(&commit)?; + ( + removed, + delta, + empty_revision, + selection_resync, + result, + closed_public_ids, + terminal_runtimes, + host_cleanup, + ) }; - let mut fields = Map::from_iter([("url".into(), Value::String(url))]); - if let Some((cols, rows)) = size { - let (cell_width, cell_height) = self.cell_pixel_size(); - fields.insert("width_px".into(), Value::from(u64::from(cols) * u64::from(cell_width))); - fields - .insert("height_px".into(), Value::from(u64::from(rows) * u64::from(cell_height))); + let terminal_revision_after = registry.terminal_snapshot()?.revision; + if terminal_revision_after != terminal_revision_before { + self.emit_terminal_registry_changed(®istry, terminal_revision_after); + } + self.emit_committed_workspace_delta(®istry, delta, selection_resync); + drop(registry); + self.notify_terminal_exit_waiters(closed_public_ids); + if project_resource { + self.publish_resource_event(); + } + for surface in removed { + self.purge_surface_side_tables(surface.id); + if surface.kind() == SurfaceKind::Browser { + surface.kill(); + } + } + #[cfg(unix)] + let runtime_identities = terminal_runtimes + .iter() + .filter_map(|runtime| self.resource_terminal_host_identity(runtime)) + .map(|identity| (identity.terminal_id, identity.incarnation)) + .collect::>(); + for runtime in terminal_runtimes { + self.purge_terminal_runtime_side_tables(&runtime); + self.terminate_terminal_runtime(&runtime); } - let commit = self.commit_ordinary_topology_operation( - ResourceOperation::TabCreateBrowser, - selectors, - fields, - )?; - self.emit_resource_topology_legacy_events(ResourceOperation::TabCreateBrowser, &commit); - self.ordinary_created_surface(&commit) + #[cfg(unix)] + let host_cleanup = host_cleanup + .into_iter() + .filter(|(_, record)| { + !runtime_identities + .contains(&(record.terminal_id.clone(), record.incarnation.clone())) + }) + .collect(); + let host_termination = self.terminate_prepared_terminal_hosts(host_cleanup); + self.emit_empty_if_current(empty_revision); + host_termination?; + Ok(result) } - pub(crate) fn new_browser_tab_reserved( - self: &Arc, - url: String, - pane: Option, - size: Option<(u16, u16)>, - resource_identity: TabResourceIdentity, - workspace_key: Option, - ) -> anyhow::Result> { - self.new_browser_tab_with_resource_identity( - url, - pane, - size, - Some(resource_identity), - workspace_key, - ) + pub fn rename_workspace(&self, target: WorkspaceId, name: String) -> bool { + self.rename_workspace_at_revision(target, name, None) + .map(|revision| revision.is_some()) + .unwrap_or(false) } - fn new_browser_tab_with_resource_identity( - self: &Arc, - url: String, - pane: Option, - size: Option<(u16, u16)>, - resource_identity: Option, - workspace_key: Option, - ) -> anyhow::Result> { - let (target, empty_workspace) = { - let state = self.state.lock().unwrap(); - let target = match pane { - Some(id) => { - if !state.panes.contains_key(&id) { - anyhow::bail!("unknown pane {id}"); - } - Some(id) - } - None => state.active_pane(), - }; - let empty_workspace = target - .is_none() - .then(|| state.workspaces.get(state.active_workspace)) - .flatten() - .filter(|workspace| workspace.screens.is_empty()) - .map(|workspace| workspace.id); - (target, empty_workspace) - }; - let Some(target) = target else { - if let Some(workspace) = empty_workspace { - return self.create_browser_surface_in_workspace( - workspace, - url, - size, - resource_identity, - ); - } - let workspace_key = match workspace_key { - Some(workspace_key) => workspace_key, - None => Self::new_workspace_key()?, - }; - let surface = self.spawn_browser_surface_with_resource_identity( - url, - size, + pub fn rename_workspace_at_revision( + &self, + target: WorkspaceId, + name: String, + expected_revision: Option, + ) -> anyhow::Result> { + Ok(self + .rename_workspace_selector_with_authority( + Some(target), None, - resource_identity, - )?; - let (pane_id, pane) = self.make_pane(surface.id)?; - let screen_id = self.next_id(); - let ws_id = self.next_id(); - let notifications = self.surface_notifications(); - if let Some(workspace_id) = empty_workspace { - let delta = { - let mut state = self.state.lock().unwrap(); - let Some(workspace_index) = - state.workspaces.iter().position(|workspace| workspace.id == workspace_id) - else { - state.surfaces.remove(&surface.id); - surface.kill(); - anyhow::bail!("workspace disappeared while creating browser tab"); - }; - state.insert_pane(pane); - stamp_pane_focus(self, &mut state, pane_id); - state.workspaces[workspace_index].screens.push(Screen { - id: screen_id, - public_id: ScreenPublicId::random()?, - name: None, - root: Node::Leaf(pane_id), - active_pane: pane_id, - zoomed_pane: None, - zellij_auto_layout: Some(vec![pane_id]), - viewport_splits: Default::default(), - viewport_base_width: None, - layout_columns: Vec::new(), - layout_revision: 0, - layout_undo: Default::default(), - }); - state.workspaces[workspace_index].active_screen = 0; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::ScreenAdded, - screen_id, - ) - .expect("first workspace screen is present in tree snapshot"); - TreeDelta { - kind: TreeDeltaKind::ScreenAdded, - workspace: workspace_id, - screen: Some(screen_id), - pane: None, - surface: None, - index: Some(0), - entity, - workspace_revision: None, - } - }; - self.emit(MuxEvent::TreeDelta(delta)); - self.reap_if_dead(&surface); - return Ok(surface); - } - let mutation = WorkspaceMutation::local("cmux-tui"); - let workspace_public_id = WorkspacePublicId::random()?; - let mut registry = self.workspace_registry.lock().unwrap(); - let delta = { - let mut state = self.state.lock().unwrap(); - let name = Self::default_workspace_name(&state); - let index = state.workspaces.len(); - let mut desired = self.registry_projection(&state); - desired.push(RegistryWorkspace { - id: ws_id, - public_id: workspace_public_id.clone(), - key: workspace_key.clone(), - name: name.clone(), - group_key: self.session.clone(), - }); - let commit = match registry.commit( - &mutation, - &serde_json::json!({ - "op": "new-browser-workspace", - "workspace": ws_id, - "key": workspace_key.clone(), - "name": name, - }), - None, - None, - "workspace-added", - &workspace_key, - &desired, - &serde_json::json!({ - "workspace": ws_id, - "workspace_id": workspace_public_id.as_str(), - "key": workspace_key.clone(), - "index": index, - }), - ) { - Ok(commit) => commit, - Err(error) => { - drop(state); - drop(registry); - self.discard_spawned(vec![surface]); - return Err(error); - } - }; - state.insert_pane(pane); - stamp_pane_focus(self, &mut state, pane_id); - state.push_workspace(Workspace { - id: ws_id, - public_id: workspace_public_id, - key: workspace_key, - name, - screens: vec![Screen { - id: screen_id, - public_id: ScreenPublicId::random()?, - name: None, - root: Node::Leaf(pane_id), - active_pane: pane_id, - zoomed_pane: None, - zellij_auto_layout: Some(vec![pane_id]), - viewport_splits: Default::default(), - viewport_base_width: None, - layout_columns: Vec::new(), - layout_revision: 0, - layout_undo: Default::default(), - }], - active_screen: 0, - }); - state.active_workspace = state.workspaces.len() - 1; - state.workspace_revision = commit.revision; - let workspace_revision = commit.revision; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::WorkspaceAdded, - ws_id, - ) - .expect("new workspace is present in tree snapshot"); - TreeDelta { - kind: TreeDeltaKind::WorkspaceAdded, - workspace: ws_id, - screen: None, - pane: None, - surface: None, - index: Some(index), - entity, - workspace_revision: Some(workspace_revision), - } - }; - let selection_resync = delta.index.is_some_and(|index| index > 0); - self.emit_committed_workspace_delta(®istry, delta, selection_resync); - drop(registry); - self.reap_if_dead(&surface); - return Ok(surface); - }; + name, + expected_revision, + WorkspaceMutationAuthority::Ordinary, + )? + .map(|(_, _, revision)| revision)) + } + + #[allow(clippy::too_many_arguments)] + pub fn rename_workspace_with_mutation( + &self, + target: Option, + requested_key: Option<&str>, + name: String, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + let authority = self.authorize_workspace_lifecycle_mutation( + WorkspaceMutationAuthority::Ordinary, + "rename", + )?; + let result = self.rename_workspace_with_mutation_inner( + target, + requested_key, + name, + expected_generation, + expected_revision, + mutation, + ); + drop(authority); + result + } - let surface = - self.spawn_browser_surface_with_resource_identity(url, size, None, resource_identity)?; - let active_at = self.next_active_at(); - let notifications = self.surface_notifications(); - let attached = { - let mut state = self.state.lock().unwrap(); - match state.panes.get_mut(&target) { - Some(pane) => { - pane.tabs.push(surface.id); - pane.active_tab = pane.tabs.len() - 1; - pane.active_at = active_at; - let index = pane.tabs.len() - 1; - fence_layout_undo_for_tab_membership(&mut state, &[target]); - let (wi, si) = state.screen_of(target).expect("live pane belongs to a screen"); - let workspace = state.workspaces[wi].id; - let screen = state.workspaces[wi].screens[si].id; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::TabAdded, - surface.id, - ) - .expect("new browser tab is present in tree snapshot"); - Some(TreeDelta { - kind: TreeDeltaKind::TabAdded, - workspace, - screen: Some(screen), - pane: Some(target), - surface: Some(surface.id), - index: Some(index), - entity, - workspace_revision: None, - }) - } - None => { - state.surfaces.remove(&surface.id); - None - } - } + pub fn rename_provider_managed_workspace( + &self, + id: WorkspaceId, + key: &str, + name: String, + ) -> anyhow::Result> { + Ok(self + .rename_workspace_selector_with_authority( + Some(id), + Some(key), + name, + None, + WorkspaceMutationAuthority::TrustedProvider, + )? + .map(|(_, _, revision)| revision)) + } + + pub(crate) fn rename_provider_managed_workspace_authorized( + &self, + id: WorkspaceId, + key: &str, + name: String, + authority: &str, + ) -> anyhow::Result> { + Ok(self + .rename_workspace_selector_with_authority( + Some(id), + Some(key), + name, + None, + WorkspaceMutationAuthority::ProviderCredential(authority), + )? + .map(|(_, _, revision)| revision)) + } + + fn rename_workspace_selector_with_authority( + &self, + id: Option, + key: Option<&str>, + name: String, + expected_revision: Option, + authorization: WorkspaceMutationAuthority<'_>, + ) -> anyhow::Result> { + let authority = self.authorize_workspace_lifecycle_mutation(authorization, "rename")?; + let resolved = { + let state = self.state.lock().unwrap(); + Self::require_workspace_revision(&state, expected_revision)?; + Self::resolve_workspace_selector(&state, id, key)? }; - let Some(delta) = attached else { - surface.kill(); - anyhow::bail!("pane disappeared while creating browser tab"); + let Some((resolved_target, _)) = resolved else { + return Ok(None); }; - self.emit_tree_delta(delta, true); - self.reap_if_dead(&surface); - Ok(surface) + let mutation = WorkspaceMutation::local("cmux-tui"); + let result = self.rename_workspace_with_mutation_inner( + id, + key, + name, + None, + expected_revision, + &mutation, + ); + drop(authority); + let result = result?; + Ok(Some((result.workspace.unwrap_or(resolved_target), result.key, result.revision))) } - fn create_browser_surface_in_workspace( - self: &Arc, - workspace: WorkspaceId, - url: String, - size: Option<(u16, u16)>, - resource_identity: Option, - ) -> anyhow::Result> { - let lifecycle = self.workspace_lifecycle(workspace); - let workspace_lifecycle = lifecycle.lock().unwrap(); - if self.state.lock().unwrap().workspace_by_id(workspace).is_none() { - anyhow::bail!("unknown workspace {workspace}"); - } - let surface = self.spawn_browser_surface_with_resource_identity( - url, - size, - Some(workspace), - resource_identity, - )?; - let pending_surface = self.pending_workspace_surface(surface.id); + #[allow(clippy::too_many_arguments)] + fn rename_workspace_with_mutation_inner( + &self, + target: Option, + requested_key: Option<&str>, + name: String, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + Self::validate_workspace_name(&name)?; + let fingerprint = serde_json::json!({ + "op": "rename-workspace", + "workspace": target, + "key": requested_key, + "name": name, + }); let notifications = self.surface_notifications(); - let active_at = self.next_active_at(); - let (delta, selection_resync) = { + let mut registry = self.workspace_registry.lock().unwrap(); + if let Some(commit) = registry.replay(mutation, &fingerprint)? { + return workspace_mutation_result(&commit); + } + let (renamed, result) = { let mut state = self.state.lock().unwrap(); - let Some(wi) = state.workspace_index(workspace) else { - state.surfaces.remove(&surface.id); - surface.kill(); - anyhow::bail!("workspace disappeared while creating browser tab"); - }; - let target = state.workspaces[wi].active_screen_ref().map(|screen| screen.active_pane); - if let Some(target) = target { - let Some((_, si)) = state.screen_of(target) else { - state.surfaces.remove(&surface.id); - surface.kill(); - anyhow::bail!("workspace active pane disappeared while creating browser tab"); - }; - let Some(pane) = state.panes.get_mut(&target) else { - state.surfaces.remove(&surface.id); - surface.kill(); - anyhow::bail!("workspace active pane disappeared while creating browser tab"); - }; - pane.tabs.push(surface.id); - pane.active_tab = pane.tabs.len() - 1; - pane.active_at = active_at; - let index = pane.tabs.len() - 1; - fence_layout_undo_for_tab_membership(&mut state, &[target]); - let screen = state.workspaces[wi].screens[si].id; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::TabAdded, - surface.id, - ) - .expect("new browser tab is present in tree snapshot"); - ( - TreeDelta { - kind: TreeDeltaKind::TabAdded, - workspace, - screen: Some(screen), - pane: Some(target), - surface: Some(surface.id), - index: Some(index), - entity, - workspace_revision: None, - }, - true, - ) - } else { - let (pane_id, pane) = self.make_pane(surface.id)?; - let screen_id = self.next_id(); - state.insert_pane(pane); - stamp_pane_focus(self, &mut state, pane_id); - state.workspaces[wi].screens.push(Screen { - id: screen_id, - public_id: ScreenPublicId::random()?, - name: None, - root: Node::Leaf(pane_id), - active_pane: pane_id, - zoomed_pane: None, - zellij_auto_layout: Some(vec![pane_id]), - viewport_splits: Default::default(), - viewport_base_width: None, - layout_columns: Vec::new(), - layout_revision: 0, - layout_undo: Default::default(), - }); - state.workspaces[wi].active_screen = 0; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::ScreenAdded, - screen_id, - ) - .expect("first browser screen is present in tree snapshot"); - ( - TreeDelta { - kind: TreeDeltaKind::ScreenAdded, - workspace, - screen: Some(screen_id), - pane: None, - surface: None, - index: Some(0), - entity, - workspace_revision: None, - }, - false, - ) - } + Self::require_workspace_revision(&state, expected_revision)?; + let index = resolve_workspace_index(&state, target, requested_key)?; + let workspace_id = state.workspaces[index].id; + let key = state.workspaces[index].key.clone(); + let changed = state.workspaces[index].name != name; + let mut desired = self.registry_projection(&state); + desired[index].name = name.clone(); + let desired_active_workspace = + state.workspaces.get(state.active_workspace).map(|workspace| &workspace.public_id); + let commit = registry.commit_with_active_workspace( + mutation, + &fingerprint, + expected_generation, + expected_revision, + "workspace-renamed", + &key, + &desired, + desired_active_workspace, + &serde_json::json!({ + "workspace": workspace_id, + "key": key.clone(), + "index": index, + "changed": changed, + }), + )?; + let resource_revision = registry.snapshot()?.resource_revision; + state.workspaces[index].name = name; + state.workspace_revision = commit.revision; + state.resource_revision = resource_revision; + let workspace_revision = commit.revision; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::WorkspaceRenamed, + workspace_id, + ) + .expect("renamed workspace is present in tree snapshot"); + ( + TreeDelta { + kind: TreeDeltaKind::WorkspaceRenamed, + workspace: workspace_id, + screen: None, + pane: None, + surface: None, + index: None, + entity, + workspace_revision: Some(workspace_revision), + }, + workspace_mutation_result(&commit)?, + ) }; - drop(pending_surface); - self.emit_tree_delta(delta, selection_resync); - drop(workspace_lifecycle); - self.reap_if_dead(&surface); - Ok(surface) + self.emit_committed_workspace_delta(®istry, renamed, false); + drop(registry); + self.publish_resource_event(); + Ok(result) } - pub fn adopt_browser_target( - self: &Arc, - opener_surface: SurfaceId, - target_id: String, - url: String, - runtime: Arc, - ) -> anyhow::Result { - let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); - let (pane_id, size) = { - let state = self.state.lock().unwrap(); - let Some(pane_id) = state.pane_of(opener_surface) else { - return Ok(false); - }; - let size = state.surfaces.get(&opener_surface).map(|surface| surface.size()); - (pane_id, size) - }; - let id = self.next_id(); - let opts = self.surface_options.lock().unwrap().clone(); - let size = size.unwrap_or((opts.cols, opts.rows)); - let cell_pixels = self.cell_pixel_creation_size(); - let surface = - browser::new_surface(id, url.clone(), size, cell_pixels, &opts, Arc::downgrade(self))?; - let active_at = self.next_active_at(); - let attached = - match self.attach_browser_surface_to_pane_or_kill(pane_id, &surface, active_at) { - BrowserSurfaceAttach::MissingPane => return Ok(false), - BrowserSurfaceAttach::Attached(delta) => delta, - }; - let identity = surface - .resource_identity() - .context("adopted browser surface omitted its public identity")?; - let result = serde_json::json!({ - "tab_id":identity.tab_id, - "browser_id":identity.content_id, - }); - if let Err(error) = - self.commit_ordinary_full_resource_projection("browser.target.adopt", result) + /// Set a pane's user-visible name. An empty name clears it (the pane + /// falls back to its active tab's title). + pub fn rename_pane(self: &Arc, target: PaneId, name: String) -> bool { + let Some(selectors) = self.ordinary_pane_selectors(target) else { return false }; + if self + .commit_ordinary_topology_operation( + ResourceOperation::PaneRename, + selectors, + Self::nullable_name_fields(name), + ) + .is_err() { - let rollback = self.close_surface_for_resource_effect(surface.id); - return match rollback { - Ok(true) => Err(error.context("could not persist adopted browser target")), - Ok(false) => Err(error.context( - "could not persist adopted browser target and its tab disappeared during rollback", - )), - Err(rollback) => Err(error.context(format!( - "could not persist adopted browser target; rollback also failed: {rollback:#}" - ))), - }; - } - if let Some(delta) = attached { - self.emit_tree_delta(delta, true); - } else { - self.emit(MuxEvent::TreeChanged); + return false; } - self.start_browser_bootstrap( - surface, - BrowserBootstrap::ExistingTarget { target_id, url }, - Some(runtime), - ); - Ok(true) + self.emit(MuxEvent::TreeChanged); + true } - fn attach_browser_surface_to_pane_or_kill( - &self, - pane_id: PaneId, - surface: &Arc, - active_at: u64, - ) -> BrowserSurfaceAttach { + /// Set a tab's user-visible name. An empty name clears it (the tab + /// falls back to its process title/number label). + pub fn rename_surface(self: &Arc, target: SurfaceId, name: String) -> bool { + let Some(selectors) = self.ordinary_tab_selectors(target) else { return false }; + if self + .commit_ordinary_topology_operation( + ResourceOperation::TabRename, + selectors, + Self::nullable_name_fields(name), + ) + .is_err() + { + return false; + } let notifications = self.surface_notifications(); - let attached = { - let mut state = self.state.lock().unwrap(); - match state.panes.get_mut(&pane_id) { - Some(pane) => { - pane.tabs.push(surface.id); - pane.active_tab = pane.tabs.len() - 1; - pane.active_at = active_at; - fence_layout_undo_for_tab_membership(&mut state, &[pane_id]); - state.surfaces.insert(surface.id, surface.clone()); - let delta = (|| { - let (wi, si) = state.screen_of(pane_id)?; - let pane = state.panes.get(&pane_id)?; - let index = pane.tabs.iter().position(|id| *id == surface.id)?; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::TabAdded, - surface.id, - )?; - Some(TreeDelta { - kind: TreeDeltaKind::TabAdded, - workspace: state.workspaces[wi].id, - screen: Some(state.workspaces[wi].screens[si].id), - pane: Some(pane_id), - surface: Some(surface.id), - index: Some(index), - entity, - workspace_revision: None, - }) - })(); - BrowserSurfaceAttach::Attached(delta) - } - None => BrowserSurfaceAttach::MissingPane, + let delta = { + let state = self.state.lock().unwrap(); + if !state.surfaces.contains_key(&target) { + return false; } + (|| { + let pane = state.pane_of(target)?; + let (wi, si) = state.screen_of(pane)?; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::TabRenamed, + target, + )?; + Some(TreeDelta { + kind: TreeDeltaKind::TabRenamed, + workspace: state.workspaces[wi].id, + screen: Some(state.workspaces[wi].screens[si].id), + pane: Some(pane), + surface: Some(target), + index: None, + entity, + workspace_revision: None, + }) + })() }; - if matches!(attached, BrowserSurfaceAttach::MissingPane) { - surface.kill(); + match delta { + Some(delta) => self.emit(MuxEvent::TreeDelta(delta)), + None => self.emit(MuxEvent::TreeChanged), } - attached + true } - /// Working directory of a pane's active surface, if reported. - fn pane_cwd(&self, pane: PaneId) -> Option { - let surface = { + /// Set a screen's user-visible name. An empty name clears it (the + /// screen falls back to its number). + pub fn rename_screen(self: &Arc, target: ScreenId, name: String) -> bool { + let Some(selectors) = self.ordinary_screen_selectors(target) else { return false }; + if self + .commit_ordinary_topology_operation( + ResourceOperation::ScreenRename, + selectors, + Self::nullable_name_fields(name), + ) + .is_err() + { + return false; + } + let notifications = self.surface_notifications(); + let renamed = { let state = self.state.lock().unwrap(); - let active = state.panes.get(&pane)?.active_surface()?; - state.surfaces.get(&active).cloned() + let Some(wi) = state + .workspaces + .iter() + .enumerate() + .find_map(|(wi, workspace)| { + workspace + .screens + .iter() + .position(|screen| screen.id == target) + .map(|si| (wi, si)) + }) + .map(|(wi, _)| wi) + else { + return false; + }; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::ScreenRenamed, + target, + ) + .expect("renamed screen is present in tree snapshot"); + TreeDelta { + kind: TreeDeltaKind::ScreenRenamed, + workspace: state.workspaces[wi].id, + screen: Some(target), + pane: None, + surface: None, + index: None, + entity, + workspace_revision: None, + } }; - surface.and_then(|surface| surface.local_cwd()) - } - - fn workspace_key_for_pane(&self, pane: PaneId) -> Option { - let state = self.state.lock().unwrap(); - let (workspace, _) = state.screen_of(pane)?; - Some(state.workspaces[workspace].key.clone()) + self.emit(MuxEvent::TreeDelta(renamed)); + true } - /// Split the screen containing `target`, putting a new single-tab - /// pane after it. Returns the new pane's surface. `size` is the - /// expected content size of the new pane, when the caller knows it. - pub fn split( - self: &Arc, - target: PaneId, - dir: SplitDir, - size: Option<(u16, u16)>, - ) -> anyhow::Result> { - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let selectors = self - .ordinary_pane_selectors(target) - .with_context(|| format!("unknown pane {target}"))?; - let direction = match dir { - SplitDir::Right => "right", - SplitDir::Down => "down", + /// Reconcile a surface whose child exited before its tree insert + /// completed. Hosted terminals commit their exit and detach every view; + /// local terminals are reaped after the creator closes the insert race. + fn reap_if_dead(self: &Arc, surface: &Arc) { + if surface.is_dead() { + if self.resource_creation_active.load(Ordering::Acquire) { + // The reader's exit callback is fenced by the creation + // execution lock. It will reap this surface after the public + // creation batch and any legacy runtime handoff complete. + return; + } + if let Some(identity) = self.resource_terminal_host_identity(surface) { + if let Err(error) = + self.mark_hosted_surface_exited(surface, "host-exited-before-attach") + { + self.emit(MuxEvent::Status(format!( + "could not persist terminal {} exit: {error}", + surface.id + ))); + self.schedule_exited_terminal_detach( + identity.terminal_id, + surface, + "host-exited-before-attach", + ); + return; + } + return; + } + self.remove_surface_after_registry(surface.id); + return; + } + let workspace_key = { + let state = self.state.lock().unwrap(); + state.workspaces.iter().find_map(|workspace| { + workspace + .screens + .iter() + .any(|screen| screen_tabs(&state, screen).contains(&surface.id)) + .then(|| workspace.key.clone()) + }) }; - let mut fields = Map::from_iter([("direction".into(), Value::String(direction.into()))]); - Self::insert_cell_size(&mut fields, size); - let commit = self.commit_ordinary_topology_operation( - ResourceOperation::PaneSplit, - selectors, - fields, - )?; - self.emit_resource_topology_legacy_events(ResourceOperation::PaneSplit, &commit); - self.ordinary_created_surface(&commit) + if let Some(workspace_key) = workspace_key { + let _ = surface.persist_host_workspace(&workspace_key); + } } - /// Add a terminal as a viewport-width column after the target's column. - /// - /// Updated frontends render the new column at `width` times their own - /// viewport width. The ordinary split ratio remains valid fallback data - /// for older clients. - pub fn new_pane_right( - self: &Arc, - target: PaneId, - width: f32, - size: Option<(u16, u16)>, - ) -> anyhow::Result> { + /// Called by a surface's reader thread when its child exits. Hosted + /// terminals preserve a durable exit receipt while all views detach; + /// local surfaces are removed immediately. + pub fn surface_exited(self: &Arc, id: SurfaceId) { + if self.sidebar_surface_exited(id) { + self.emit(MuxEvent::SurfaceExited(id)); + return; + } let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - if !width.is_finite() - || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) + let _creation_fence = self.resource_creation_execution.lock().unwrap(); + if let Some(surface) = self.surface(id) + && let Some(identity) = self.resource_terminal_host_identity(&surface) { - return Err(ViewportWidthError::OutOfRange { width }.into()); + if let Err(error) = self.mark_hosted_surface_exited(&surface, "host-exited") { + self.emit(MuxEvent::Status(format!( + "could not persist terminal {id} exit: {error}" + ))); + self.schedule_exited_terminal_detach(identity.terminal_id, &surface, "host-exited"); + return; + } + return; } - let selectors = self - .ordinary_pane_selectors(target) - .with_context(|| format!("unknown pane {target}"))?; - let mut fields = Map::from_iter([ - ("direction".into(), Value::String("right".into())), - ("viewport_width".into(), Value::from(width)), - ]); - Self::insert_cell_size(&mut fields, size); - let commit = self - .commit_ordinary_topology_operation(ResourceOperation::PaneSplit, selectors, fields) - .map_err(|error| { - eprintln!("cmux-tui: viewport pane PTY creation failed: {error:#}"); - anyhow::anyhow!("pane creation failed") - })?; - self.emit_resource_topology_legacy_events(ResourceOperation::PaneSplit, &commit); - self.ordinary_created_surface(&commit) + self.remove_surface_after_registry(id); + self.emit(MuxEvent::SurfaceExited(id)); } - /// Create a pane and reapply Zellij's default pane distribution to the - /// containing screen. The screen stores creation order independently of - /// the mutable split tree, so swaps and directional splits cannot reorder - /// terminals when automatic layout resumes. - pub fn new_pane( + /// Exit persistence is first-writer-wins. A transient SQLite failure + /// leaves the old topology intact and retries through a weak owner until + /// the atomic lifecycle-and-detach commit succeeds or shutdown begins. + fn schedule_exited_terminal_detach( self: &Arc, - target: PaneId, - size: Option<(u16, u16)>, - ) -> anyhow::Result> { - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let selectors = self - .ordinary_pane_selectors(target) - .with_context(|| format!("unknown pane {target}"))?; - let mut fields = Map::new(); - Self::insert_cell_size(&mut fields, size); - let commit = self.commit_ordinary_topology_operation( - ResourceOperation::PaneCreate, - selectors, - fields, - )?; - self.emit_resource_topology_legacy_events(ResourceOperation::PaneCreate, &commit); - self.ordinary_created_surface(&commit) + terminal_id: String, + surface: &Arc, + reason: &'static str, + ) { + let Some(detach_lease) = self.terminal_exit_detaches.acquire(terminal_id.clone()) else { + return; + }; + let cleanup_id = terminal_id.clone(); + let mux = Arc::downgrade(self); + let surface = Arc::downgrade(surface); + let spawn_result = std::thread::Builder::new() + .name(format!("terminal-exit-detach-{terminal_id}")) + .spawn(move || { + let _detach_lease = detach_lease; + let mut delay = Duration::from_millis(25); + loop { + std::thread::sleep(delay); + let Some(mux) = mux.upgrade() else { break }; + if mux.shutting_down.load(Ordering::Acquire) { + break; + } + let reconciled = surface + .upgrade() + .map(|surface| mux.mark_hosted_surface_exited(&surface, reason)) + .unwrap_or_else(|| { + mux.detach_exited_terminal_topology(&terminal_id).map(drop) + }); + match reconciled { + Ok(()) => break, + Err(error) => { + eprintln!( + "cmux-tui: could not detach exited terminal \ + {terminal_id}: {error:#}" + ); + delay = (delay * 2).min(Duration::from_secs(5)); + } + } + } + }); + if let Err(error) = spawn_result { + eprintln!( + "cmux-tui: could not schedule exited terminal {cleanup_id} detach: {error:#}" + ); + } } - /// Close one tab. When it was the pane's last tab, the pane collapses - /// out of its split tree. Empty workspace containers remain durable; - /// only an explicit close-workspace mutation removes a workspace. - pub fn close_surface(self: &Arc, target: SurfaceId) -> anyhow::Result { - let Some(selectors) = self.ordinary_tab_selectors(target) else { return Ok(false) }; - let commit = self - .commit_ordinary_topology_operation(ResourceOperation::TabClose, selectors, Map::new()) - .with_context(|| format!("close surface {target}"))?; - self.emit_resource_topology_legacy_events(ResourceOperation::TabClose, &commit); - Ok(true) + fn mark_hosted_surface_exited( + &self, + surface: &Arc, + reason: &str, + ) -> anyhow::Result<()> { + let Some(identity) = self.resource_terminal_host_identity(surface) else { + return Ok(()); + }; + // Output stays on the bounded asynchronous ingress path. Exit is the + // one terminal transition that fences it, preserving byte order and + // full topology subjects before the atomic detach transaction. + self.flush_terminal_journal()?; + let exit = surface.terminal_exit().unwrap_or_else(|| TerminalExit::unknown(reason)); + self.persist_terminal_exit(&identity.terminal_id, Some(&identity.incarnation), &exit)?; + self.detach_exited_terminal_topology(&identity.terminal_id)?; + #[cfg(unix)] + if let Some((path, expected)) = surface.terminal_host_exit_sidecar() { + crate::terminal_host_runtime::acknowledge_terminal_host_exit_record(&path, &expected)?; + } + Ok(()) } - pub(crate) fn close_surface_for_resource_effect( + /// Commit terminal lifecycle, topology detach, and exactly one public + /// event in one registry transaction. Callers may observe the same exit + /// through the live frame, sidecar recovery, and dead-host reconciliation; + /// the first commit is the latch and all later observations are no-ops. + fn persist_terminal_exit( &self, - target: SurfaceId, + terminal_id: &str, + incarnation: Option<&str>, + exit: &TerminalExit, ) -> anyhow::Result { - Ok(self.remove_surface_after_registry(target)) - } - - fn remove_surface_after_registry(&self, target: SurfaceId) -> bool { - let notifications = self.surface_notifications(); - let remove = || { - let mut state = self.state.lock().unwrap(); - let selection_before = active_tree_selection(&state); - let changed_screen = surface_screen_id(&state, target); - let delta = close_surface_delta(&state, ¬ifications, target); - let (removed, split_index_dirty) = remove_surface(self, &mut state, target); - if split_index_dirty { - Self::rebuild_split_screen_index(&mut state); - } - let empty_revision = state.workspaces.is_empty().then_some(state.workspace_revision); - let selection_resync = - empty_revision.is_none() && selection_before != active_tree_selection(&state); - let changed = removed.is_some() || delta.is_some(); - ( - removed, - changed_screen.into_iter().collect::>(), - empty_revision, - delta, - selection_resync, - changed, - ) - }; - let (removed, changed_screens, empty_revision, delta, selection_resync, changed) = loop { - let Some(workspace) = self.surface_workspace(target) else { - break remove(); - }; - let lifecycle = self.workspace_lifecycle(workspace); - let workspace_lifecycle = lifecycle.lock().unwrap(); - if self.surface_workspace(target) != Some(workspace) { - drop(workspace_lifecycle); - continue; - } - let result = remove(); - drop(workspace_lifecycle); - break result; + let mut registry = self.workspace_registry.lock().unwrap(); + let terminal = registry + .terminal_record(terminal_id)? + .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; + let public_terminal_id = registry.terminal_resource_id(terminal_id)?; + if !matches!(terminal.lifecycle, TerminalLifecycle::Exited | TerminalLifecycle::Tombstoned) + && public_terminal_id.is_none() + { + // One-release compatibility for pre-resource terminal rows. They + // have no public identity to emit, but still retain the exact + // outcome in the terminal timeline and remain first-writer wins. + let resource_revision = registry.resource_revision()?; + let (_, terminal_revision) = commit_terminal_lifecycle( + &mut registry, + "terminal-exited", + "terminal-host-exited", + terminal_id, + TerminalLifecycle::Exited, + incarnation, + Some(serde_json::json!({ + "outcome": &exit.outcome, + "exited_at": exit.exited_at_ms.to_string(), + "revision": resource_revision.to_string(), + })), + )?; + self.emit_terminal_registry_changed(®istry, terminal_revision); + return Ok(true); + } + let mut state = self.state.lock().unwrap(); + let terminal_snapshot = if matches!( + terminal.lifecycle, + TerminalLifecycle::Exited | TerminalLifecycle::Tombstoned + ) { + Value::Null + } else { + terminal_exit_snapshot_in_state(®istry, &state, terminal_id)? }; - if let Some(surface) = &removed { - self.purge_surface_side_tables(surface.id); - if surface.kind() == SurfaceKind::Browser { - surface.kill(); + let detach_projection = if matches!( + terminal.lifecycle, + TerminalLifecycle::Exited | TerminalLifecycle::Tombstoned + ) { + None + } else if let Some(public_terminal_id) = public_terminal_id.as_ref() { + self.terminal_exit_detach_projection_locked( + ®istry, + &state, + terminal_id, + public_terminal_id, + )? + } else { + None + }; + let topology = + detach_projection.as_ref().map(|projection| (&projection.patch, &projection.changes)); + let (_, terminal_revision, resource_revision, replayed) = registry.commit_terminal_exit( + terminal_id, + incarnation, + exit, + terminal_snapshot, + topology, + )?; + let mut detach_effects = None; + if !replayed { + if let Some(projection) = detach_projection { + detach_effects = Some(projection.install(&mut state, resource_revision)); + } else { + state.resource_revision = resource_revision; } + self.emit_terminal_registry_changed(®istry, terminal_revision); } - if let Some(delta) = delta { - self.emit_tree_delta(delta, selection_resync); - } else if removed.is_some() { - self.emit(MuxEvent::TreeChanged); - } - if removed.is_some() || !changed_screens.is_empty() { - for screen in changed_screens { - self.emit(MuxEvent::LayoutChanged(screen)); + drop(state); + drop(registry); + if !replayed { + if let Some(public_terminal_id) = public_terminal_id.as_ref() { + self.terminal_exit_waiters.notify(public_terminal_id); + } + self.publish_resource_event(); + if let Some(effects) = detach_effects { + self.finish_terminal_exit_detach(effects); } } - self.emit_empty_if_current(empty_revision); - changed + Ok(!replayed) } - /// Close a pane or screen while holding the target workspace's lifecycle - /// lock. Tabs are detached view items; terminal content remains in the - /// catalog until an explicit terminal close. - fn close_tree_target(&self, target: TreeCloseTarget) -> anyhow::Result { - let notifications = self.surface_notifications(); - let result = loop { - let Some(workspace) = - self.with_state(|state| Self::workspace_for_tree_target_in_state(state, target)) - else { - return Ok(false); - }; - let lifecycle = self.workspace_lifecycle(workspace); - let workspace_lifecycle = lifecycle.lock().unwrap(); - if self.with_state(|state| Self::workspace_for_tree_target_in_state(state, target)) - != Some(workspace) - { - drop(workspace_lifecycle); - continue; - } - let result = (|| -> anyhow::Result> { - let mut state = self.state.lock().unwrap(); - let selection_before = active_tree_selection(&state); - let (tabs, delta) = match target { - TreeCloseTarget::Pane(target) => { - let Some(pane) = state.panes.get(&target) else { return Ok(None) }; - ( - pane.tabs.clone(), - close_pane_delta(&state, ¬ifications, target) - .expect("live pane has a close delta"), - ) - } - TreeCloseTarget::Screen(target) => { - let Some(screen) = state - .workspaces - .iter() - .flat_map(|workspace| &workspace.screens) - .find(|screen| screen.id == target) - else { - return Ok(None); - }; - ( - screen_tabs(&state, screen), - close_screen_delta(&state, ¬ifications, target) - .expect("live screen has a close delta"), - ) - } - }; - let changed_screens = unique_screen_ids( - tabs.iter().filter_map(|surface| surface_screen_id(&state, *surface)), - ); - let mut removed = Vec::new(); - let mut split_index_dirty = false; - for surface in tabs { - let (surface, topology_changed) = remove_surface(self, &mut state, surface); - split_index_dirty |= topology_changed; - if let Some(surface) = surface { - removed.push(surface); - } - } - if split_index_dirty { - Self::rebuild_split_screen_index(&mut state); - } - let tree_removed = match target { - TreeCloseTarget::Pane(target) => !state.panes.contains_key(&target), - TreeCloseTarget::Screen(target) => !state - .workspaces - .iter() - .flat_map(|workspace| &workspace.screens) - .any(|screen| screen.id == target), - }; - let empty_revision = - state.workspaces.is_empty().then_some(state.workspace_revision); - let selection_resync = - empty_revision.is_none() && selection_before != active_tree_selection(&state); - Ok(Some(( - removed, - changed_screens, - empty_revision, - delta, - tree_removed, - selection_resync, - ))) - })(); - let result = result?; - drop(workspace_lifecycle); - break result; - }; - let Some((removed, changed_screens, empty_revision, delta, tree_removed, selection_resync)) = - result - else { - return Ok(false); + fn sidebar_surface_exited(&self, id: SurfaceId) -> bool { + let mut runtime = self.sidebar_plugin.lock().unwrap(); + if runtime.surface != Some(id) { + return false; + } + runtime.surface = None; + runtime.failures = runtime.failures.saturating_add(1); + let delay = sidebar_retry_delay(runtime.failures); + runtime.last_error = Some("sidebar plugin exited".to_string()); + runtime.retry_at = Some(Instant::now() + delay); + drop(runtime); + let retired = { + let mut state = self.state.lock().unwrap(); + take_surface_for_retirement(self, &mut state, id) }; - for surface in removed { - self.purge_surface_side_tables(surface.id); - if surface.kind() == SurfaceKind::Browser { - surface.kill(); - } + if let Some(surface) = retired { + self.purge_surface_side_tables(id); + self.retire_surface_runtime(surface); } - if tree_removed { - self.emit_tree_delta(delta, selection_resync); - for screen in changed_screens { - self.emit(MuxEvent::LayoutChanged(screen)); - } + true + } + + /// Make `pane` the active pane of its screen (and that screen and + /// workspace active). + pub fn focus_pane(self: &Arc, pane: PaneId) -> bool { + let layout_changed = self.with_state(|state| { + let (workspace, screen) = state.screen_of(pane)?; + let screen = &state.workspaces[workspace].screens[screen]; + (screen.active_pane != pane + && (screen.root.contains_stack_pane(screen.active_pane) + || screen.root.contains_stack_pane(pane))) + .then_some(screen.id) + }); + let Some(selectors) = self.ordinary_pane_selectors(pane) else { return false }; + if self + .commit_ordinary_topology_operation(ResourceOperation::PaneFocus, selectors, Map::new()) + .is_err() + { + return false; } - self.emit_empty_if_current(empty_revision); - Ok(true) + let viewed = self.with_state(Self::active_surface_in_state); + self.clear_viewed_notification(viewed); + if let Some(screen) = layout_changed { + self.emit(MuxEvent::LayoutChanged(screen)); + } else { + self.emit(MuxEvent::TreeChanged); + }; + true } - /// Close a pane and every tab in it. - pub fn close_pane(self: &Arc, target: PaneId) -> anyhow::Result { - let Some(selectors) = self.ordinary_pane_selectors(target) else { return Ok(false) }; - let commit = self - .commit_ordinary_topology_operation(ResourceOperation::PaneClose, selectors, Map::new()) - .with_context(|| format!("close pane {target}"))?; - self.emit_resource_topology_legacy_events(ResourceOperation::PaneClose, &commit); - Ok(true) + /// Set the deepest split ratio in `dir` on the path to `pane`. + pub fn set_ratio(self: &Arc, pane: PaneId, dir: SplitDir, ratio: f32) -> bool { + self.set_ratio_checked(pane, dir, ratio).is_ok() } - pub(crate) fn close_pane_for_resource_effect(&self, target: PaneId) -> anyhow::Result { - self.close_tree_target(TreeCloseTarget::Pane(target)) - .with_context(|| format!("close pane {target}")) + /// Set a pane-addressed split ratio while preserving rejection details. + pub fn set_ratio_checked( + self: &Arc, + pane: PaneId, + dir: SplitDir, + ratio: f32, + ) -> Result<(), LayoutRatioError> { + let split = self + .with_state(|state| { + state + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .find(|screen| screen.root.contains(pane)) + .and_then(|screen| screen.root.deepest_split_for_pane(pane, dir)) + }) + .ok_or(LayoutRatioError::UnknownPaneSplit { pane })?; + self.set_split_ratio_inner(split, ratio, None, true).map_err(|error| match error { + LayoutRatioError::UnknownSplit { .. } => LayoutRatioError::UnknownPaneSplit { pane }, + error => error, + }) } - /// Close a screen and every pane/tab in it. - pub fn close_screen(self: &Arc, target: ScreenId) -> anyhow::Result { - let Some(selectors) = self.ordinary_screen_selectors(target) else { return Ok(false) }; - let commit = self - .commit_ordinary_topology_operation( - ResourceOperation::ScreenClose, - selectors, - Map::new(), - ) - .with_context(|| format!("close screen {target}"))?; - self.emit_resource_topology_legacy_events(ResourceOperation::ScreenClose, &commit); - Ok(true) + /// Set one split ratio by its stable split-tree node id. + pub fn set_split_ratio(self: &Arc, split: SplitId, ratio: f32) -> bool { + self.set_split_ratio_checked(split, ratio).is_ok() } - pub(crate) fn close_screen_for_resource_effect( - &self, - target: ScreenId, - ) -> anyhow::Result { - self.close_tree_target(TreeCloseTarget::Screen(target)) - .with_context(|| format!("close screen {target}")) + /// Set one split ratio while preserving rejection details. + pub fn set_split_ratio_checked( + self: &Arc, + split: SplitId, + ratio: f32, + ) -> Result<(), LayoutRatioError> { + self.set_split_ratio_inner(split, ratio, None, false) } - /// Close a workspace and every screen/pane/tab in it. - pub fn close_workspace(&self, target: WorkspaceId) -> bool { - self.close_workspace_at_revision(target, None) - .map(|revision| revision.is_some()) - .unwrap_or(false) + /// Set one split ratio as part of a client-scoped resize transaction. + pub fn set_split_ratio_in_transaction( + self: &Arc, + split: SplitId, + ratio: f32, + client: u64, + transaction: u64, + ) -> bool { + self.set_split_ratio_in_transaction_checked(split, ratio, client, transaction).is_ok() } - /// Atomically close one workspace if the caller's registry snapshot is - /// still current. Returns the resulting revision when the workspace was - /// present and closed. - pub fn close_workspace_at_revision( - &self, - target: WorkspaceId, - expected_revision: Option, - ) -> anyhow::Result> { - Ok(self - .close_workspace_selector_at_revision(Some(target), None, expected_revision)? - .map(|(_, _, revision)| revision)) + /// Set one transactional split ratio while preserving rejection details. + pub fn set_split_ratio_in_transaction_checked( + self: &Arc, + split: SplitId, + ratio: f32, + client: u64, + transaction: u64, + ) -> Result<(), LayoutRatioError> { + self.set_split_ratio_inner( + split, + ratio, + Some((LayoutResizeOwner::ControlClient(client), transaction)), + false, + ) } - pub(crate) fn close_workspace_selector_at_revision( - &self, - id: Option, - key: Option<&str>, - expected_revision: Option, - ) -> anyhow::Result> { - self.close_workspace_selector_with_authority( - id, - key, - expected_revision, - WorkspaceMutationAuthority::Ordinary, - true, + /// Set one in-process transactional split ratio without sharing the + /// control-client ownership namespace. + pub fn set_split_ratio_in_process_transaction_checked( + self: &Arc, + split: SplitId, + ratio: f32, + owner: u64, + transaction: u64, + ) -> Result<(), LayoutRatioError> { + self.set_split_ratio_inner( + split, + ratio, + Some((LayoutResizeOwner::InProcess(owner), transaction)), + false, ) } - pub(crate) fn close_workspace_at_revision_for_resource_effect( - &self, - target: WorkspaceId, - ) -> anyhow::Result> { - Ok(self - .close_workspace_selector_with_authority( - Some(target), - None, - None, - WorkspaceMutationAuthority::Ordinary, - false, - )? - .map(|(_, _, revision)| revision)) + fn set_split_ratio_inner( + self: &Arc, + split: SplitId, + ratio: f32, + transaction: Option<(LayoutResizeOwner, u64)>, + tree_changed: bool, + ) -> Result<(), LayoutRatioError> { + let ratio = clamp_split_ratio(ratio); + let target = { + let state = self.state.lock().unwrap(); + let Some((workspace_index, screen_index, owner)) = + state.split_screens.get(&split).copied() + else { + return Err(LayoutRatioError::UnknownSplit { split }); + }; + if state + .workspaces + .get(workspace_index) + .and_then(|workspace| workspace.screens.get(screen_index)) + .is_none_or(|screen| screen.id != owner) + { + return Err(LayoutRatioError::UnknownSplit { split }); + } + let screen = &state.workspaces[workspace_index].screens[screen_index]; + if let Some(index) = screen + .layout_columns + .iter() + .position(|column| column.id == split) + .filter(|index| *index > 0) + { + let width_before = + screen.layout_columns[..index].iter().map(|column| column.width).sum::(); + let width = width_before * (1.0 - ratio) / ratio; + if !width.is_finite() + || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) + { + return Err(LayoutRatioError::UnrepresentableViewportWidth { + split, + ratio, + width, + }); + } + if screen.layout_columns[index].width == width { + return Ok(()); + } + } else { + let Some(current) = screen.root.split_ratio(split) else { + return Err(LayoutRatioError::UnknownSplit { split }); + }; + if current == ratio { + return Ok(()); + } + } + ( + screen.active_pane, + state + .resource_indexes + .split_ids + .get(&split) + .cloned() + .ok_or(LayoutRatioError::UnknownSplit { split })?, + ) + }; + let selectors = self + .ordinary_pane_selectors(target.0) + .ok_or(LayoutRatioError::UnknownSplit { split })?; + let mut fields = Map::from_iter([ + ("split_id".into(), Value::String(target.1.to_string())), + ("ratio".into(), Value::from(ratio)), + ]); + if let Some((owner, transaction)) = transaction { + let (kind, owner) = match owner { + LayoutResizeOwner::ControlClient(owner) => ("control-client", owner), + LayoutResizeOwner::InProcess(owner) => ("in-process", owner), + }; + fields.insert("resize_owner_kind".into(), Value::String(kind.into())); + fields.insert("resize_owner".into(), Value::from(owner)); + fields.insert("resize_transaction".into(), Value::from(transaction)); + } + let commit = self + .commit_ordinary_topology_operation( + ResourceOperation::PaneSplitRatioSet, + selectors, + fields, + ) + .map_err(|error| { + self.emit(MuxEvent::Status(format!("could not persist split ratio: {error:#}"))); + LayoutRatioError::UnknownSplit { split } + })?; + if tree_changed { + self.emit(MuxEvent::TreeChanged); + } + if let Some(screen) = commit + .result + .get("screen") + .and_then(Value::as_str) + .and_then(|id| ScreenPublicId::parse(id.to_string()).ok()) + .and_then(|id| { + self.with_state(|state| state.resource_indexes.screens.get(&id).copied()) + }) + { + self.emit(MuxEvent::LayoutChanged(screen)); + } + Ok(()) } - pub fn close_workspace_with_mutation( - &self, - target: Option, - requested_key: Option<&str>, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let _creation_fence = self.resource_creation_execution.lock().unwrap(); - let authority = self.authorize_workspace_lifecycle_mutation( - WorkspaceMutationAuthority::Ordinary, - "close", - )?; - let result = self.close_workspace_with_mutation_inner( - target, - requested_key, - expected_generation, - expected_revision, - mutation, - true, - ); - drop(authority); - result + /// Set the width of the horizontal viewport column containing `pane`. + pub fn set_viewport_pane_width(self: &Arc, pane: PaneId, width: f32) -> bool { + self.set_viewport_pane_width_checked(pane, width).is_ok() } - pub fn close_provider_managed_workspace( - &self, - id: WorkspaceId, - key: &str, - ) -> anyhow::Result> { - Ok(self - .close_workspace_selector_with_authority( - Some(id), - Some(key), - None, - WorkspaceMutationAuthority::TrustedProvider, - true, - )? - .map(|(_, _, revision)| revision)) + /// Set a viewport column width while preserving rejection details. + pub fn set_viewport_pane_width_checked( + self: &Arc, + pane: PaneId, + width: f32, + ) -> Result<(), ViewportWidthError> { + self.set_viewport_pane_width_inner(pane, width, None) } - pub(crate) fn close_provider_managed_workspace_authorized( - &self, - id: WorkspaceId, - key: &str, - authority: &str, - ) -> anyhow::Result> { - Ok(self - .close_workspace_selector_with_authority( - Some(id), - Some(key), - None, - WorkspaceMutationAuthority::ProviderCredential(authority), - true, - )? - .map(|(_, _, revision)| revision)) + /// Set a viewport column width as part of a client-scoped resize transaction. + pub fn set_viewport_pane_width_in_transaction( + self: &Arc, + pane: PaneId, + width: f32, + client: u64, + transaction: u64, + ) -> bool { + self.set_viewport_pane_width_in_transaction_checked(pane, width, client, transaction) + .is_ok() } - fn close_workspace_selector_with_authority( - &self, - id: Option, - key: Option<&str>, - expected_revision: Option, - authorization: WorkspaceMutationAuthority<'_>, - project_resource: bool, - ) -> anyhow::Result> { - let _creation_handoff = - project_resource.then(|| self.resource_creation_handoff.lock().unwrap()); - let _creation_fence = - project_resource.then(|| self.resource_creation_execution.lock().unwrap()); - let authority = self.authorize_workspace_lifecycle_mutation(authorization, "close")?; - let resolved = { - let state = self.state.lock().unwrap(); - Self::require_workspace_revision(&state, expected_revision)?; - Self::resolve_workspace_selector(&state, id, key)? - }; - let Some((resolved_target, _)) = resolved else { - return Ok(None); - }; - let mutation = WorkspaceMutation::local("cmux-tui"); - let result = self.close_workspace_with_mutation_inner( - id, - key, - None, - expected_revision, - &mutation, - project_resource, - ); - drop(authority); - let result = result?; - Ok(Some((result.workspace.unwrap_or(resolved_target), result.key, result.revision))) + /// Set a transactional viewport width while preserving rejection details. + pub fn set_viewport_pane_width_in_transaction_checked( + self: &Arc, + pane: PaneId, + width: f32, + client: u64, + transaction: u64, + ) -> Result<(), ViewportWidthError> { + self.set_viewport_pane_width_inner( + pane, + width, + Some((LayoutResizeOwner::ControlClient(client), transaction)), + ) } - fn close_workspace_with_mutation_inner( - &self, - target: Option, - requested_key: Option<&str>, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - project_resource: bool, - ) -> anyhow::Result { - let fingerprint = serde_json::json!({ - "op": "close-workspace", - "workspace": target, - "key": requested_key, - }); + /// Set one in-process transactional viewport width without sharing the + /// control-client ownership namespace. + pub fn set_viewport_pane_width_in_process_transaction_checked( + self: &Arc, + pane: PaneId, + width: f32, + owner: u64, + transaction: u64, + ) -> Result<(), ViewportWidthError> { + self.set_viewport_pane_width_inner( + pane, + width, + Some((LayoutResizeOwner::InProcess(owner), transaction)), + ) + } + + fn set_viewport_pane_width_inner( + self: &Arc, + pane: PaneId, + width: f32, + transaction: Option<(LayoutResizeOwner, u64)>, + ) -> Result<(), ViewportWidthError> { + if !width.is_finite() + || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) { - let registry = self.workspace_registry.lock().unwrap(); - if let Some(commit) = registry.replay(mutation, &fingerprint)? { - let result = workspace_mutation_result(&commit)?; - return Ok(result); - } + return Err(ViewportWidthError::OutOfRange { width }); } - loop { - let resolved_target = { - let state = self.state.lock().unwrap(); - Self::require_workspace_revision(&state, expected_revision)?; - let index = resolve_workspace_index(&state, target, requested_key)?; - state.workspaces[index].id + { + let state = self.state.lock().unwrap(); + let Some((workspace_index, screen_index)) = state.screen_of(pane) else { + return Err(ViewportWidthError::PaneNotResizable { pane }); }; - #[cfg(test)] - if let Some(hook) = - self.workspace_close_after_selector_resolution.lock().unwrap().clone() - { - hook(); + let screen = &state.workspaces[workspace_index].screens[screen_index]; + if !screen.layout_columns_active() { + return Err(ViewportWidthError::PaneNotResizable { pane }); } - let lifecycle = self.workspace_lifecycle(resolved_target); - let workspace_lifecycle = lifecycle.lock().unwrap(); - let current_target = { - let state = self.state.lock().unwrap(); - Self::require_workspace_revision(&state, expected_revision)?; - let index = resolve_workspace_index(&state, target, requested_key)?; - state.workspaces[index].id + let Some(column_index) = + screen.layout_columns.iter().position(|column| column.root.contains(pane)) + else { + return Err(ViewportWidthError::PaneNotResizable { pane }); }; - if current_target != resolved_target { - drop(workspace_lifecycle); - continue; + if (screen.layout_columns[column_index].width - width).abs() < f32::EPSILON { + return Ok(()); } - let result = self.close_workspace_with_mutation_locked( - target, - requested_key, - expected_generation, - expected_revision, - mutation, - &fingerprint, - resolved_target, - project_resource, - ); - drop(workspace_lifecycle); - return result; - } - } - - #[allow(clippy::too_many_arguments)] - fn close_workspace_with_mutation_locked( - &self, - target: Option, - requested_key: Option<&str>, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - fingerprint: &Value, - resolved_target: WorkspaceId, - project_resource: bool, - ) -> anyhow::Result { - let notifications = self.surface_notifications(); - let mut registry = self.workspace_registry.lock().unwrap(); - if let Some(commit) = registry.replay(mutation, fingerprint)? { - let result = workspace_mutation_result(&commit)?; - return Ok(result); } - let (removed, delta, empty_revision, selection_resync, result) = { - let mut state = self.state.lock().unwrap(); - Self::require_workspace_revision(&state, expected_revision)?; - let index = resolve_workspace_index(&state, target, requested_key)?; - let workspace_id = state.workspaces[index].id; - if workspace_id != resolved_target { - anyhow::bail!("workspace selector changed while closing"); - } - let previous_active = state.active_pane(); - let key = state.workspaces[index].key.clone(); - let mut desired = self.registry_projection(&state); - desired.remove(index); - let desired_active_workspace = if state.active_workspace == index { - desired.last().map(|workspace| &workspace.public_id) - } else { - state.workspaces.get(state.active_workspace).map(|workspace| &workspace.public_id) - }; - let committed_result = serde_json::json!({ - "workspace": workspace_id, - "key": key, - "index": index, - "changed": true, - }); - let commit = if project_resource { - registry.commit_with_active_workspace( - mutation, - fingerprint, - expected_generation, - expected_revision, - "workspace-closed", - &key, - &desired, - desired_active_workspace, - &committed_result, - )? - } else { - registry.commit_for_resource_effect( - mutation, - fingerprint, - expected_generation, - expected_revision, - "workspace-closed", - &key, - &desired, - desired_active_workspace, - &committed_result, - )? + let selectors = self + .ordinary_pane_selectors(pane) + .ok_or(ViewportWidthError::PaneNotResizable { pane })?; + let mut fields = Map::from_iter([("width".into(), Value::from(width))]); + if let Some((owner, transaction)) = transaction { + let (kind, owner) = match owner { + LayoutResizeOwner::ControlClient(owner) => ("control-client", owner), + LayoutResizeOwner::InProcess(owner) => ("in-process", owner), }; - let resource_revision = project_resource - .then(|| registry.snapshot().map(|snapshot| snapshot.resource_revision)) - .transpose()?; - let mut delta = close_workspace_delta(&state, ¬ifications, workspace_id) - .expect("live workspace has a close delta"); - let was_active = state.active_workspace == index; - let active_id = - state.workspaces.get(state.active_workspace).map(|workspace| workspace.id); - let workspace = state.remove_workspace(index); - let mut pane_ids = Vec::new(); - for screen in &workspace.screens { - screen.root.pane_ids(&mut pane_ids); - } - let mut removed = Vec::new(); - for pane_id in pane_ids { - if let Some(pane) = state.remove_pane(pane_id) { - for surface in pane.tabs { - if let Some(surface) = state.surfaces.remove(&surface) { - removed.push(surface); - } - } - } - } - state.active_workspace = active_id - .and_then(|id| state.workspace_index(id)) - .unwrap_or_else(|| state.workspaces.len().saturating_sub(1)); - stamp_changed_active_pane(self, &mut state, previous_active); - Self::rebuild_split_screen_index(&mut state); - state.workspace_revision = commit.revision; - if let Some(resource_revision) = resource_revision { - state.resource_revision = resource_revision; - } - delta.workspace_revision = Some(commit.revision); - let empty_revision = state.workspaces.is_empty().then_some(state.workspace_revision); - let selection_resync = was_active && empty_revision.is_none(); - let result = workspace_mutation_result(&commit)?; - (removed, delta, empty_revision, selection_resync, result) - }; - self.emit_committed_workspace_delta(®istry, delta, selection_resync); - drop(registry); - if project_resource { - self.publish_resource_event(); + fields.insert("resize_owner_kind".into(), Value::String(kind.into())); + fields.insert("resize_owner".into(), Value::from(owner)); + fields.insert("resize_transaction".into(), Value::from(transaction)); } - for surface in removed { - self.purge_surface_side_tables(surface.id); - if surface.kind() == SurfaceKind::Browser { - surface.kill(); + let commit = self + .commit_ordinary_topology_operation( + ResourceOperation::PaneViewportWidthSet, + selectors, + fields, + ) + .map_err(|error| { + self.emit(MuxEvent::Status(format!( + "could not persist viewport pane width: {error:#}" + ))); + ViewportWidthError::PaneNotResizable { pane } + })?; + if let Some(screen) = commit + .result + .get("screen") + .and_then(Value::as_str) + .and_then(|id| ScreenPublicId::parse(id.to_string()).ok()) + .and_then(|id| { + self.with_state(|state| state.resource_indexes.screens.get(&id).copied()) + }) + { + self.emit(MuxEvent::LayoutChanged(screen)); + } + Ok(()) + } + + fn pane_navigation_layout(screen: &Screen, pane: PaneId, dir: Direction) -> LayoutResult { + const NAVIGATION_COLUMN_WIDTH: u16 = 10_000; + let column_area = Rect { x: 0, y: 0, width: NAVIGATION_COLUMN_WIDTH, height: 10_000 }; + if !screen.layout_columns_active() { + return layout_screen(&screen.root, column_area, Some(screen.active_pane)); + } + let Some(current_index) = + screen.layout_columns.iter().position(|column| column.root.contains(pane)) + else { + return layout_screen(&screen.root, column_area, Some(screen.active_pane)); + }; + let neighbor_index = match dir { + Direction::Up | Direction::Down => None, + Direction::Left => current_index.checked_sub(1), + Direction::Right => { + current_index.checked_add(1).filter(|index| *index < screen.layout_columns.len()) } + }; + let Some(neighbor_index) = neighbor_index else { + return layout_screen( + &screen.layout_columns[current_index].root, + column_area, + Some(screen.active_pane), + ); + }; + + let (left_index, right_index) = if neighbor_index < current_index { + (neighbor_index, current_index) + } else { + (current_index, neighbor_index) + }; + let mut result = LayoutResult { virtual_width: 20_000, ..Default::default() }; + for (index, x) in [(left_index, 0), (right_index, NAVIGATION_COLUMN_WIDTH)] { + let mut column = layout_screen( + &screen.layout_columns[index].root, + Rect { x, ..column_area }, + Some(screen.active_pane), + ); + result.panes.append(&mut column.panes); + result.stacked_headers.extend(column.stacked_headers); } - self.emit_empty_if_current(empty_revision); - Ok(result) + result } - pub fn rename_workspace(&self, target: WorkspaceId, name: String) -> bool { - self.rename_workspace_at_revision(target, name, None) - .map(|revision| revision.is_some()) - .unwrap_or(false) + pub fn pane_neighbor(&self, pane: PaneId, dir: Direction) -> anyhow::Result> { + self.with_state(|state| { + let Some((wi, si)) = state.screen_of(pane) else { + anyhow::bail!("unknown pane {pane}"); + }; + let screen = &state.workspaces[wi].screens[si]; + let (dx, dy) = dir.delta(); + let layout = Self::pane_navigation_layout(screen, pane, dir); + Ok(layout.neighbor(pane, dx, dy)) + }) } - pub fn rename_workspace_at_revision( - &self, - target: WorkspaceId, - name: String, - expected_revision: Option, - ) -> anyhow::Result> { - Ok(self - .rename_workspace_selector_with_authority( - Some(target), - None, - name, - expected_revision, - WorkspaceMutationAuthority::Ordinary, - )? - .map(|(_, _, revision)| revision)) + #[cfg(test)] + fn pane_focus_neighbor(&self, pane: PaneId, dir: Direction) -> anyhow::Result> { + self.with_state(|state| { + let Some((wi, si)) = state.screen_of(pane) else { + anyhow::bail!("unknown pane {pane}"); + }; + let screen = &state.workspaces[wi].screens[si]; + let (dx, dy) = dir.delta(); + let layout = Self::pane_navigation_layout(screen, pane, dir); + Ok(layout.neighbor_by_recency(pane, dx, dy, |candidate| { + state.panes.get(&candidate).map(|pane| pane.focused_at).unwrap_or_default() + })) + }) } - #[allow(clippy::too_many_arguments)] - pub fn rename_workspace_with_mutation( - &self, - target: Option, - requested_key: Option<&str>, - name: String, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - let authority = self.authorize_workspace_lifecycle_mutation( - WorkspaceMutationAuthority::Ordinary, - "rename", + pub fn focus_direction( + self: &Arc, + pane: Option, + dir: Direction, + ) -> anyhow::Result { + let target = self.with_state(|state| pane.or_else(|| state.active_pane())); + let Some(target) = target else { + anyhow::bail!("no active pane"); + }; + let selectors = self + .ordinary_pane_selectors(target) + .with_context(|| format!("unknown pane {target}"))?; + let direction = match dir { + Direction::Left => "left", + Direction::Right => "right", + Direction::Up => "up", + Direction::Down => "down", + }; + let commit = self.commit_ordinary_topology_operation( + ResourceOperation::PaneFocusDirection, + selectors, + Map::from_iter([("direction".into(), Value::String(direction.into()))]), )?; - let result = self.rename_workspace_with_mutation_inner( - target, - requested_key, - name, - expected_generation, - expected_revision, - mutation, - ); - drop(authority); - result + let public_id = PanePublicId::parse( + commit.result["pane"] + .as_str() + .context("pane focus result omitted its pane id")? + .to_string(), + )?; + let next = self + .with_state(|state| state.resource_indexes.panes.get(&public_id).copied()) + .context("focused pane disappeared")?; + let viewed = self.with_state(Self::active_surface_in_state); + self.clear_viewed_notification(viewed); + self.emit(MuxEvent::TreeChanged); + Ok(next) } - pub fn rename_provider_managed_workspace( - &self, - id: WorkspaceId, - key: &str, - name: String, - ) -> anyhow::Result> { - Ok(self - .rename_workspace_selector_with_authority( - Some(id), - Some(key), - name, - None, - WorkspaceMutationAuthority::TrustedProvider, - )? - .map(|(_, _, revision)| revision)) + pub fn swap_panes(self: &Arc, pane: PaneId, target: PaneId) -> bool { + if pane == target { + return false; + } + let Some(selectors) = self.ordinary_pane_selectors(pane) else { return false }; + let Some((other_workspace, other_screen, other_pane)) = self.with_state(|state| { + let first = state.screen_of(pane)?; + let second = state.screen_of(target)?; + if first != second { + return None; + } + Some(( + state.workspaces[second.0].public_id.to_string(), + state.workspaces[second.0].screens[second.1].public_id.to_string(), + state.resource_indexes.pane_ids.get(&target)?.to_string(), + )) + }) else { + return false; + }; + let fields = Map::from_iter([ + ("other_workspace".into(), Value::String(other_workspace)), + ("other_screen".into(), Value::String(other_screen)), + ("other_pane".into(), Value::String(other_pane)), + ]); + let Ok(commit) = + self.commit_ordinary_topology_operation(ResourceOperation::PaneSwap, selectors, fields) + else { + return false; + }; + self.emit_resource_topology_legacy_events(ResourceOperation::PaneSwap, &commit); + true } - pub(crate) fn rename_provider_managed_workspace_authorized( - &self, - id: WorkspaceId, - key: &str, - name: String, - authority: &str, - ) -> anyhow::Result> { - Ok(self - .rename_workspace_selector_with_authority( - Some(id), - Some(key), - name, - None, - WorkspaceMutationAuthority::ProviderCredential(authority), - )? - .map(|(_, _, revision)| revision)) + pub fn zoom_pane( + self: &Arc, + pane: Option, + mode: ZoomMode, + ) -> anyhow::Result { + let (target, next, changed) = self.with_state(|state| { + let target = pane.or_else(|| state.active_pane()).context("no active pane")?; + let (workspace, screen) = + state.screen_of(target).with_context(|| format!("unknown pane {target}"))?; + let current = state.workspaces[workspace].screens[screen].zoomed_pane; + let next = match mode { + ZoomMode::Toggle if current == Some(target) => None, + ZoomMode::Toggle | ZoomMode::On => Some(target), + ZoomMode::Off => None, + }; + Ok::<_, anyhow::Error>((target, next, current != next)) + })?; + if !changed { + return Ok(ZoomState { pane: target, zoomed: next.is_some(), zoomed_pane: next }); + } + let selectors = self + .ordinary_pane_selectors(target) + .with_context(|| format!("unknown pane {target}"))?; + let fields = match mode { + ZoomMode::Toggle => Map::new(), + ZoomMode::On => Map::from_iter([("enabled".into(), Value::Bool(true))]), + ZoomMode::Off => Map::from_iter([("enabled".into(), Value::Bool(false))]), + }; + let commit = self.commit_ordinary_topology_operation( + ResourceOperation::PaneZoom, + selectors, + fields, + )?; + self.emit_resource_topology_legacy_events(ResourceOperation::PaneZoom, &commit); + let zoomed_pane = self.with_state(|state| { + state + .screen_of(target) + .and_then(|(workspace, screen)| { + state.workspaces.get(workspace)?.screens.get(screen) + }) + .and_then(|screen| screen.zoomed_pane) + }); + Ok(ZoomState { pane: target, zoomed: zoomed_pane.is_some(), zoomed_pane }) } - fn rename_workspace_selector_with_authority( - &self, - id: Option, - key: Option<&str>, - name: String, + /// Undo the latest structural layout transaction on `pane`'s screen. + /// + /// Transactions that created panes return a confirmation preview first. + /// The preview is read only. The caller must retry with its exact current + /// layout revision and `confirm_close=true`; structural or created-pane tab + /// membership changes advance that revision before the retry can commit. + pub fn undo_layout( + self: &Arc, + pane: PaneId, expected_revision: Option, - authorization: WorkspaceMutationAuthority<'_>, - ) -> anyhow::Result> { - let authority = self.authorize_workspace_lifecycle_mutation(authorization, "rename")?; - let resolved = { + confirm_close: bool, + ) -> anyhow::Result { + let (screen_id, current_revision, created_panes) = { let state = self.state.lock().unwrap(); - Self::require_workspace_revision(&state, expected_revision)?; - Self::resolve_workspace_selector(&state, id, key)? - }; - let Some((resolved_target, _)) = resolved else { - return Ok(None); + let Some((workspace_index, screen_index)) = state.screen_of(pane) else { + return Err(LayoutUndoError::Stale( + "layout undo target is no longer available".to_string(), + ) + .into()); + }; + let screen = &state.workspaces[workspace_index].screens[screen_index]; + let Some(entry) = screen.layout_undo.back() else { + return Err(LayoutUndoError::Unavailable.into()); + }; + if entry.after_revision != screen.layout_revision { + return Err(LayoutUndoError::Stale( + "layout changed since the last undoable action".to_string(), + ) + .into()); + } + if let Some(expected) = expected_revision + && expected != entry.after_revision + { + return Err(LayoutUndoError::Stale(format!( + "layout revision conflict: expected {expected}, current {}", + entry.after_revision + )) + .into()); + } + for created in &entry.created_panes { + if !state.panes.contains_key(created) { + return Err(LayoutUndoError::Stale(format!( + "created pane {created} disappeared before undo preview" + )) + .into()); + } + } + (screen.id, entry.after_revision, entry.created_panes.clone()) }; - let mutation = WorkspaceMutation::local("cmux-tui"); - let result = self.rename_workspace_with_mutation_inner( - id, - key, - name, - None, - expected_revision, - &mutation, - ); - drop(authority); - let result = result?; - Ok(Some((result.workspace.unwrap_or(resolved_target), result.key, result.revision))) - } - - #[allow(clippy::too_many_arguments)] - fn rename_workspace_with_mutation_inner( - &self, - target: Option, - requested_key: Option<&str>, - name: String, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - Self::validate_workspace_name(&name)?; - let fingerprint = serde_json::json!({ - "op": "rename-workspace", - "workspace": target, - "key": requested_key, - "name": name, - }); - let notifications = self.surface_notifications(); - let mut registry = self.workspace_registry.lock().unwrap(); - if let Some(commit) = registry.replay(mutation, &fingerprint)? { - return workspace_mutation_result(&commit); + if !created_panes.is_empty() && !confirm_close { + return Ok(LayoutUndoResult::ConfirmationRequired { + screen: screen_id, + revision: current_revision, + closes_panes: created_panes, + }); } - let (renamed, result) = { - let mut state = self.state.lock().unwrap(); - Self::require_workspace_revision(&state, expected_revision)?; - let index = resolve_workspace_index(&state, target, requested_key)?; - let workspace_id = state.workspaces[index].id; - let key = state.workspaces[index].key.clone(); - let changed = state.workspaces[index].name != name; - let mut desired = self.registry_projection(&state); - desired[index].name = name.clone(); - let desired_active_workspace = - state.workspaces.get(state.active_workspace).map(|workspace| &workspace.public_id); - let commit = registry.commit_with_active_workspace( - mutation, - &fingerprint, - expected_generation, - expected_revision, - "workspace-renamed", - &key, - &desired, - desired_active_workspace, - &serde_json::json!({ - "workspace": workspace_id, - "key": key.clone(), - "index": index, - "changed": changed, - }), - )?; - let resource_revision = registry.snapshot()?.resource_revision; - state.workspaces[index].name = name; - state.workspace_revision = commit.revision; - state.resource_revision = resource_revision; - let workspace_revision = commit.revision; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::WorkspaceRenamed, - workspace_id, - ) - .expect("renamed workspace is present in tree snapshot"); - ( - TreeDelta { - kind: TreeDeltaKind::WorkspaceRenamed, - workspace: workspace_id, - screen: None, - pane: None, - surface: None, - index: None, - entity, - workspace_revision: Some(workspace_revision), - }, - workspace_mutation_result(&commit)?, + if !created_panes.is_empty() && expected_revision.is_none() { + return Err(LayoutUndoError::Stale( + "confirmed layout undo requires the preview revision".to_string(), ) - }; - self.emit_committed_workspace_delta(®istry, renamed, false); - drop(registry); - self.publish_resource_event(); - Ok(result) - } + .into()); + } - /// Set a pane's user-visible name. An empty name clears it (the pane - /// falls back to its active tab's title). - pub fn rename_pane(self: &Arc, target: PaneId, name: String) -> bool { - let Some(selectors) = self.ordinary_pane_selectors(target) else { return false }; - if self - .commit_ordinary_topology_operation( - ResourceOperation::PaneRename, + let selectors = self.ordinary_screen_selectors(screen_id).ok_or_else(|| { + LayoutUndoError::Stale("layout undo target is no longer available".to_string()) + })?; + let mut fields = Map::from_iter([("confirm_close".into(), Value::Bool(confirm_close))]); + fields.insert("expected_layout_revision".into(), Value::from(current_revision)); + let expected_resource_revision = if created_panes.is_empty() { + None + } else { + let registry = self.workspace_registry.lock().unwrap(); + let state = self.state.lock().unwrap(); + let Some((workspace_index, screen_index)) = state.screen_of(pane) else { + return Err(LayoutUndoError::Stale( + "layout undo target disappeared before confirmation".to_string(), + ) + .into()); + }; + let details = + layout_undo_confirmation_details(&state, ®istry, workspace_index, screen_index)?; + let token = details["confirmation_token"] + .as_str() + .context("layout undo confirmation omitted its token")?; + fields.insert("confirmation_token".into(), Value::String(token.to_string())); + Some(registry.resource_topology_snapshot()?.revision) + }; + let commit = self + .commit_resource_topology_operation( + ResourceOperation::ScreenLayoutUndo, selectors, - Self::nullable_name_fields(name), + fields, + expected_resource_revision, + &WorkspaceMutation::local("cmux-tui-layout-undo"), ) - .is_err() - { - return false; - } - self.emit(MuxEvent::TreeChanged); - true + .map_err(|error| { + if error + .downcast_ref::() + .is_some_and(|error| error.code == "revision.conflict") + { + anyhow::Error::new(LayoutUndoError::Stale( + "layout revision conflict: resource topology changed before confirmed undo could commit" + .to_string(), + )) + } else { + error + } + })?; + let screen = commit + .result + .get("screen") + .and_then(Value::as_str) + .and_then(|id| ScreenPublicId::parse(id.to_string()).ok()) + .and_then(|id| { + self.with_state(|state| state.resource_indexes.screens.get(&id).copied()) + }) + .unwrap_or(screen_id); + let revision = self + .with_state(|state| { + state + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .find(|candidate| candidate.id == screen) + .map(|screen| screen.layout_revision) + }) + .ok_or_else(|| { + LayoutUndoError::Stale( + "layout undo screen disappeared after the change committed".to_string(), + ) + })?; + Ok(LayoutUndoResult::Undone { screen, revision }) } - /// Set a tab's user-visible name. An empty name clears it (the tab - /// falls back to its process title/number label). - pub fn rename_surface(self: &Arc, target: SurfaceId, name: String) -> bool { - let Some(selectors) = self.ordinary_tab_selectors(target) else { return false }; - if self - .commit_ordinary_topology_operation( - ResourceOperation::TabRename, - selectors, - Self::nullable_name_fields(name), - ) - .is_err() - { - return false; - } - let notifications = self.surface_notifications(); - let delta = { + fn undo_layout_with_confirmation_token_for_resource_effect( + &self, + pane: PaneId, + expected_revision: Option, + confirm_close: bool, + confirmation_token: Option<&str>, + ) -> anyhow::Result { + let (workspace, screen_id, preview) = { let state = self.state.lock().unwrap(); - if !state.surfaces.contains_key(&target) { - return false; + let Some((workspace_index, screen_index)) = state.screen_of(pane) else { + return Err(LayoutUndoError::Stale( + "layout undo target is no longer available".to_string(), + ) + .into()); + }; + let workspace = state.workspaces[workspace_index].id; + let screen_id = state.workspaces[workspace_index].screens[screen_index].id; + let entry = { + let screen = &state.workspaces[workspace_index].screens[screen_index]; + let Some(entry) = screen.layout_undo.back().cloned() else { + return Err(LayoutUndoError::Unavailable.into()); + }; + if entry.after_revision != screen.layout_revision { + return Err(LayoutUndoError::Stale( + "layout changed since the last undoable action".to_string(), + ) + .into()); + } + entry + }; + if let Some(expected) = expected_revision + && expected != entry.after_revision + { + return Err(LayoutUndoError::Stale(format!( + "layout revision conflict: expected {expected}, current {}", + entry.after_revision + )) + .into()); } - (|| { - let pane = state.pane_of(target)?; - let (wi, si) = state.screen_of(pane)?; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::TabRenamed, - target, - )?; - Some(TreeDelta { - kind: TreeDeltaKind::TabRenamed, - workspace: state.workspaces[wi].id, - screen: Some(state.workspaces[wi].screens[si].id), - pane: Some(pane), - surface: Some(target), - index: None, - entity, - workspace_revision: None, - }) - })() + if !entry.created_panes.is_empty() && !confirm_close { + for created in &entry.created_panes { + if !state.panes.contains_key(created) { + return Err(LayoutUndoError::Stale(format!( + "created pane {created} disappeared before undo preview" + )) + .into()); + } + } + return Ok(LayoutUndoResult::ConfirmationRequired { + screen: screen_id, + revision: entry.after_revision, + closes_panes: entry.created_panes, + }); + } + (workspace, screen_id, entry) }; - match delta { - Some(delta) => self.emit(MuxEvent::TreeDelta(delta)), - None => self.emit(MuxEvent::TreeChanged), - } - true - } - - /// Set a screen's user-visible name. An empty name clears it (the - /// screen falls back to its number). - pub fn rename_screen(self: &Arc, target: ScreenId, name: String) -> bool { - let Some(selectors) = self.ordinary_screen_selectors(target) else { return false }; - if self - .commit_ordinary_topology_operation( - ResourceOperation::ScreenRename, - selectors, - Self::nullable_name_fields(name), + if !preview.created_panes.is_empty() && expected_revision.is_none() { + return Err(LayoutUndoError::Stale( + "confirmed layout undo requires the preview revision".to_string(), ) - .is_err() - { - return false; + .into()); + } + if preview.created_panes.is_empty() { + let revision = { + let mut state = self.state.lock().unwrap(); + let Some((workspace_index, screen_index)) = state.screen_of(pane) else { + return Err(LayoutUndoError::Stale( + "layout undo target disappeared before the change could commit".to_string(), + ) + .into()); + }; + let screen = &mut state.workspaces[workspace_index].screens[screen_index]; + let Some(entry) = screen.layout_undo.pop_back() else { + return Err( + LayoutUndoError::Stale("layout undo disappeared".to_string()).into() + ); + }; + if entry.after_revision != screen.layout_revision + || expected_revision.is_some_and(|expected| expected != entry.after_revision) + { + screen.layout_undo.push_back(entry); + return Err(LayoutUndoError::Stale( + "layout changed before undo could commit".to_string(), + ) + .into()); + } + let revision = screen.layout_revision.saturating_add(1); + screen.restore_layout_snapshot(entry.before); + screen.layout_revision = revision; + if let Some(previous) = screen.layout_undo.back_mut() { + previous.after_revision = revision; + previous.coalesce = None; + } + Self::rebuild_split_screen_index(&mut state); + revision + }; + self.emit(MuxEvent::TreeChanged); + self.emit(MuxEvent::LayoutChanged(screen_id)); + return Ok(LayoutUndoResult::Undone { screen: screen_id, revision }); } + + let lifecycle = self.workspace_lifecycle(workspace); + let _workspace_lifecycle = lifecycle.lock().unwrap(); let notifications = self.surface_notifications(); - let renamed = { - let state = self.state.lock().unwrap(); - let Some(wi) = state - .workspaces + let registry = self.workspace_registry.lock().unwrap(); + let (removed, deltas, selection_resync, revision) = { + let mut state = self.state.lock().unwrap(); + let Some(workspace_index) = state.workspace_index(workspace) else { + return Err(LayoutUndoError::Stale( + "layout undo workspace is no longer available".to_string(), + ) + .into()); + }; + let Some(screen_index) = state.workspaces[workspace_index] + .screens .iter() - .enumerate() - .find_map(|(wi, workspace)| { - workspace - .screens - .iter() - .position(|screen| screen.id == target) - .map(|si| (wi, si)) - }) - .map(|(wi, _)| wi) + .position(|screen| screen.id == screen_id) else { - return false; + return Err(LayoutUndoError::Stale( + "layout undo screen is no longer available".to_string(), + ) + .into()); }; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::ScreenRenamed, - target, - ) - .expect("renamed screen is present in tree snapshot"); - TreeDelta { - kind: TreeDeltaKind::ScreenRenamed, - workspace: state.workspaces[wi].id, - screen: Some(target), - pane: None, - surface: None, - index: None, - entity, - workspace_revision: None, - } - }; - self.emit(MuxEvent::TreeDelta(renamed)); - true - } + let entry = { + let screen = &state.workspaces[workspace_index].screens[screen_index]; + let Some(entry) = screen.layout_undo.back().cloned() else { + return Err( + LayoutUndoError::Stale("layout undo disappeared".to_string()).into() + ); + }; + if entry.after_revision != screen.layout_revision + || expected_revision != Some(entry.after_revision) + { + return Err(LayoutUndoError::Stale( + "layout changed before confirmed undo could commit".to_string(), + ) + .into()); + } + entry + }; + let mut remaining_history = + state.workspaces[workspace_index].screens[screen_index].layout_undo.clone(); + remaining_history.pop_back(); - /// Reconcile a surface whose child exited before its tree insert - /// completed. Hosted terminals commit their exit and detach every view; - /// local terminals are reaped after the creator closes the insert race. - fn reap_if_dead(self: &Arc, surface: &Arc) { - if surface.is_dead() { - if self.resource_creation_active.load(Ordering::Acquire) { - // The reader's exit callback is fenced by the creation - // execution lock. It will reap this surface after the public - // creation batch and any legacy runtime handoff complete. - return; + let mut before_panes = Vec::new(); + entry.before.root.pane_ids(&mut before_panes); + let before_panes = before_panes.into_iter().collect::>(); + let mut current_panes = Vec::new(); + state.workspaces[workspace_index].screens[screen_index] + .root + .pane_ids(&mut current_panes); + let expected_panes = before_panes + .iter() + .copied() + .chain(entry.created_panes.iter().copied()) + .collect::>(); + if current_panes.into_iter().collect::>() != expected_panes { + return Err(LayoutUndoError::Stale( + "screen panes changed since the action being undone".to_string(), + ) + .into()); } - if let Some(identity) = self.resource_terminal_host_identity(surface) { - if let Err(error) = - self.mark_hosted_surface_exited(surface, "host-exited-before-attach") - { - self.emit(MuxEvent::Status(format!( - "could not persist terminal {} exit: {error}", - surface.id + if let Some(expected_token) = confirmation_token { + let details = layout_undo_confirmation_details( + &state, + ®istry, + workspace_index, + screen_index, + )?; + if details["confirmation_token"].as_str() != Some(expected_token) { + return Err(anyhow::Error::new(ResourceError::new( + "confirmation.required", + "layout undo confirmation is stale", + details, + false, ))); - self.schedule_exited_terminal_detach( - identity.terminal_id, - surface, - "host-exited-before-attach", - ); - return; } - return; } - self.remove_surface_after_registry(surface.id); - return; - } - let workspace_key = { - let state = self.state.lock().unwrap(); - state.workspaces.iter().find_map(|workspace| { - workspace - .screens - .iter() - .any(|screen| screen_tabs(&state, screen).contains(&surface.id)) - .then(|| workspace.key.clone()) - }) - }; - if let Some(workspace_key) = workspace_key { - let _ = surface.persist_host_workspace(&workspace_key); - } - } - /// Called by a surface's reader thread when its child exits. Hosted - /// terminals preserve a durable exit receipt while all views detach; - /// local surfaces are removed immediately. - pub fn surface_exited(self: &Arc, id: SurfaceId) { - if self.sidebar_surface_exited(id) { - self.emit(MuxEvent::SurfaceExited(id)); - return; - } - let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); - let _creation_fence = self.resource_creation_execution.lock().unwrap(); - if let Some(surface) = self.surface(id) - && let Some(identity) = self.resource_terminal_host_identity(&surface) - { - if let Err(error) = self.mark_hosted_surface_exited(&surface, "host-exited") { - self.emit(MuxEvent::Status(format!( - "could not persist terminal {id} exit: {error}" - ))); - self.schedule_exited_terminal_detach(identity.terminal_id, &surface, "host-exited"); - return; + let selection_before = active_tree_selection(&state); + let mut tabs = Vec::new(); + let mut deltas = Vec::new(); + for created in &entry.created_panes { + let Some(pane) = state.panes.get(created) else { + return Err(LayoutUndoError::Stale(format!( + "created pane {created} disappeared before undo" + )) + .into()); + }; + tabs.extend(pane.tabs.iter().copied()); + if let Some(delta) = close_pane_delta(&state, ¬ifications, *created) { + deltas.push(delta); + } } - return; - } - self.remove_surface_after_registry(id); - self.emit(MuxEvent::SurfaceExited(id)); - } - - /// Exit persistence is first-writer-wins. A transient SQLite failure - /// leaves the old topology intact and retries through a weak owner until - /// the atomic lifecycle-and-detach commit succeeds or shutdown begins. - fn schedule_exited_terminal_detach( - self: &Arc, - terminal_id: String, - surface: &Arc, - reason: &'static str, - ) { - let Some(detach_lease) = self.terminal_exit_detaches.acquire(terminal_id.clone()) else { - return; - }; - let cleanup_id = terminal_id.clone(); - let mux = Arc::downgrade(self); - let surface = Arc::downgrade(surface); - let spawn_result = std::thread::Builder::new() - .name(format!("terminal-exit-detach-{terminal_id}")) - .spawn(move || { - let _detach_lease = detach_lease; - let mut delay = Duration::from_millis(25); - loop { - std::thread::sleep(delay); - let Some(mux) = mux.upgrade() else { break }; - if mux.shutting_down.load(Ordering::Acquire) { - break; - } - let reconciled = surface - .upgrade() - .map(|surface| mux.mark_hosted_surface_exited(&surface, reason)) - .unwrap_or_else(|| { - mux.detach_exited_terminal_topology(&terminal_id).map(drop) - }); - match reconciled { - Ok(()) => break, - Err(error) => { - eprintln!( - "cmux-tui: could not detach exited terminal \ - {terminal_id}: {error:#}" - ); - delay = (delay * 2).min(Duration::from_secs(5)); - } - } + let mut removed = Vec::new(); + for surface in tabs { + if let (Some(surface), _) = remove_surface(self, &mut state, surface) { + removed.push(surface); } - }); - if let Err(error) = spawn_result { - eprintln!( - "cmux-tui: could not schedule exited terminal {cleanup_id} detach: {error:#}" - ); - } - } - - fn mark_hosted_surface_exited( - &self, - surface: &Arc, - reason: &str, - ) -> anyhow::Result<()> { - let Some(identity) = self.resource_terminal_host_identity(surface) else { - return Ok(()); - }; - // Output stays on the bounded asynchronous ingress path. Exit is the - // one terminal transition that fences it, preserving byte order and - // full topology subjects before the atomic detach transaction. - self.flush_terminal_journal()?; - let exit = surface.terminal_exit().unwrap_or_else(|| TerminalExit::unknown(reason)); - self.persist_terminal_exit(&identity.terminal_id, Some(&identity.incarnation), &exit)?; - self.detach_exited_terminal_topology(&identity.terminal_id)?; - #[cfg(unix)] - if let Some((path, expected)) = surface.terminal_host_exit_sidecar() { - crate::terminal_host_runtime::acknowledge_terminal_host_exit_record(&path, &expected)?; - } - Ok(()) - } - - /// Commit terminal lifecycle, topology detach, and exactly one public - /// event in one registry transaction. Callers may observe the same exit - /// through the live frame, sidecar recovery, and dead-host reconciliation; - /// the first commit is the latch and all later observations are no-ops. - fn persist_terminal_exit( - &self, - terminal_id: &str, - incarnation: Option<&str>, - exit: &TerminalExit, - ) -> anyhow::Result { - let mut registry = self.workspace_registry.lock().unwrap(); - let terminal = registry - .terminal_record(terminal_id)? - .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; - let public_terminal_id = registry.terminal_resource_id(terminal_id)?; - if !matches!(terminal.lifecycle, TerminalLifecycle::Exited | TerminalLifecycle::Tombstoned) - && public_terminal_id.is_none() - { - // One-release compatibility for pre-resource terminal rows. They - // have no public identity to emit, but still retain the exact - // outcome in the terminal timeline and remain first-writer wins. - let resource_revision = registry.resource_revision()?; - let (_, terminal_revision) = commit_terminal_lifecycle( - &mut registry, - "terminal-exited", - "terminal-host-exited", - terminal_id, - TerminalLifecycle::Exited, - incarnation, - Some(serde_json::json!({ - "outcome": &exit.outcome, - "exited_at": exit.exited_at_ms.to_string(), - "revision": resource_revision.to_string(), - })), - )?; - self.emit_terminal_registry_changed(®istry, terminal_revision); - return Ok(true); - } - let mut state = self.state.lock().unwrap(); - let terminal_snapshot = if matches!( - terminal.lifecycle, - TerminalLifecycle::Exited | TerminalLifecycle::Tombstoned - ) { - Value::Null - } else { - terminal_exit_snapshot_in_state(®istry, &state, terminal_id)? - }; - let detach_projection = if matches!( - terminal.lifecycle, - TerminalLifecycle::Exited | TerminalLifecycle::Tombstoned - ) { - None - } else if let Some(public_terminal_id) = public_terminal_id.as_ref() { - self.terminal_exit_detach_projection_locked( - ®istry, - &state, - terminal_id, - public_terminal_id, - )? - } else { - None - }; - let topology = - detach_projection.as_ref().map(|projection| (&projection.patch, &projection.changes)); - let (_, terminal_revision, resource_revision, replayed) = registry.commit_terminal_exit( - terminal_id, - incarnation, - exit, - terminal_snapshot, - topology, - )?; - let mut detach_effects = None; - if !replayed { - if let Some(projection) = detach_projection { - detach_effects = Some(projection.install(&mut state, resource_revision)); - } else { - state.resource_revision = resource_revision; } - self.emit_terminal_registry_changed(®istry, terminal_revision); - } - drop(state); + let Some(screen_index) = state.workspaces[workspace_index] + .screens + .iter() + .position(|screen| screen.id == screen_id) + else { + return Err(LayoutUndoError::Stale( + "layout changed while undo was closing panes".to_string(), + ) + .into()); + }; + let screen = &mut state.workspaces[workspace_index].screens[screen_index]; + let revision = screen.layout_revision.max(entry.after_revision).saturating_add(1); + screen.restore_layout_snapshot(entry.before); + screen.layout_revision = revision; + screen.layout_undo = remaining_history; + if let Some(previous) = screen.layout_undo.back_mut() { + previous.after_revision = revision; + previous.coalesce = None; + } + Self::rebuild_split_screen_index(&mut state); + let selection_resync = selection_before != active_tree_selection(&state); + (removed, deltas, selection_resync, revision) + }; drop(registry); - if !replayed { - if let Some(public_terminal_id) = public_terminal_id.as_ref() { - self.terminal_exit_waiters.notify(public_terminal_id); + + for surface in removed { + self.purge_surface_side_tables(surface.id); + if surface.kind() == SurfaceKind::Browser { + surface.kill(); } - self.publish_resource_event(); - if let Some(effects) = detach_effects { - self.finish_terminal_exit_detach(effects); + } + if deltas.is_empty() { + self.emit(MuxEvent::TreeChanged); + } else { + for delta in deltas { + self.emit_tree_delta(delta, selection_resync); } } - Ok(!replayed) + self.emit(MuxEvent::LayoutChanged(screen_id)); + Ok(LayoutUndoResult::Undone { screen: screen_id, revision }) } - fn sidebar_surface_exited(&self, id: SurfaceId) -> bool { - let mut runtime = self.sidebar_plugin.lock().unwrap(); - if runtime.surface != Some(id) { - return false; + pub fn apply_layout( + self: &Arc, + workspace: Option, + name: Option, + layout: &LayoutSpec, + size: Option<(u16, u16)>, + ) -> anyhow::Result { + let _creation = self.begin_surface_creation()?; + let target_workspace = { + let state = self.state.lock().unwrap(); + if let Some(id) = workspace + && !state.workspaces.iter().any(|ws| ws.id == id) + { + anyhow::bail!("unknown workspace {id}"); + } + workspace.or_else(|| state.workspaces.get(state.active_workspace).map(|ws| ws.id)) + }; + let (target_workspace, created_workspace) = match target_workspace { + Some(workspace) => (workspace, false), + None => ( + self.create_empty_workspace_for_resource_effect( + None, + None, + WorkspacePublicId::random()?, + &WorkspaceMutation::local("cmux-tui-layout-workspace"), + )? + .workspace, + true, + ), + }; + let workspace_lifecycle = self.workspace_lifecycle(target_workspace); + let workspace_lifecycle_guard = workspace_lifecycle.lock().unwrap(); + let workspace_key = self + .state + .lock() + .unwrap() + .workspace_by_id(target_workspace) + .map(|workspace| workspace.key.clone()) + .ok_or_else(|| anyhow::anyhow!("layout workspace disappeared"))?; + #[cfg(test)] + if let Some(hook) = self.layout_apply_after_workspace_reservation.lock().unwrap().clone() { + hook(); } - runtime.surface = None; - runtime.failures = runtime.failures.saturating_add(1); - let delay = sidebar_retry_delay(runtime.failures); - runtime.last_error = Some("sidebar plugin exited".to_string()); - runtime.retry_at = Some(Instant::now() + delay); - drop(runtime); - self.state.lock().unwrap().surfaces.remove(&id); - true - } - /// Make `pane` the active pane of its screen (and that screen and - /// workspace active). - pub fn focus_pane(self: &Arc, pane: PaneId) -> bool { - let layout_changed = self.with_state(|state| { - let (workspace, screen) = state.screen_of(pane)?; - let screen = &state.workspaces[workspace].screens[screen]; - (screen.active_pane != pane - && (screen.root.contains_stack_pane(screen.active_pane) - || screen.root.contains_stack_pane(pane))) - .then_some(screen.id) - }); - let Some(selectors) = self.ordinary_pane_selectors(pane) else { return false }; - if self - .commit_ordinary_topology_operation(ResourceOperation::PaneFocus, selectors, Map::new()) - .is_err() - { - return false; - } - let viewed = self.with_state(Self::active_surface_in_state); - self.clear_viewed_notification(viewed); - if let Some(screen) = layout_changed { - self.emit(MuxEvent::LayoutChanged(screen)); - } else { - self.emit(MuxEvent::TreeChanged); + let mut created = Vec::new(); + let mut panes = Vec::new(); + let mut spawned = Vec::new(); + let root = match self.instantiate_layout( + layout, + size, + &workspace_key, + &mut panes, + &mut created, + &mut spawned, + ) { + Ok(root) => root, + Err(err) => { + self.discard_spawned(spawned); + if created_workspace { + drop(workspace_lifecycle_guard); + let _ = self.close_workspace_at_revision_for_resource_effect(target_workspace); + } + return Err(err); + } }; - true - } - - /// Set the deepest split ratio in `dir` on the path to `pane`. - pub fn set_ratio(self: &Arc, pane: PaneId, dir: SplitDir, ratio: f32) -> bool { - self.set_ratio_checked(pane, dir, ratio).is_ok() - } - - /// Set a pane-addressed split ratio while preserving rejection details. - pub fn set_ratio_checked( - self: &Arc, - pane: PaneId, - dir: SplitDir, - ratio: f32, - ) -> Result<(), LayoutRatioError> { - let split = self - .with_state(|state| { - state - .workspaces - .iter() - .flat_map(|workspace| workspace.screens.iter()) - .find(|screen| screen.root.contains(pane)) - .and_then(|screen| screen.root.deepest_split_for_pane(pane, dir)) - }) - .ok_or(LayoutRatioError::UnknownPaneSplit { pane })?; - self.set_split_ratio_inner(split, ratio, None, true).map_err(|error| match error { - LayoutRatioError::UnknownSplit { .. } => LayoutRatioError::UnknownPaneSplit { pane }, - error => error, - }) - } - - /// Set one split ratio by its stable split-tree node id. - pub fn set_split_ratio(self: &Arc, split: SplitId, ratio: f32) -> bool { - self.set_split_ratio_checked(split, ratio).is_ok() - } - - /// Set one split ratio while preserving rejection details. - pub fn set_split_ratio_checked( - self: &Arc, - split: SplitId, - ratio: f32, - ) -> Result<(), LayoutRatioError> { - self.set_split_ratio_inner(split, ratio, None, false) - } - - /// Set one split ratio as part of a client-scoped resize transaction. - pub fn set_split_ratio_in_transaction( - self: &Arc, - split: SplitId, - ratio: f32, - client: u64, - transaction: u64, - ) -> bool { - self.set_split_ratio_in_transaction_checked(split, ratio, client, transaction).is_ok() - } - - /// Set one transactional split ratio while preserving rejection details. - pub fn set_split_ratio_in_transaction_checked( - self: &Arc, - split: SplitId, - ratio: f32, - client: u64, - transaction: u64, - ) -> Result<(), LayoutRatioError> { - self.set_split_ratio_inner( - split, - ratio, - Some((LayoutResizeOwner::ControlClient(client), transaction)), - false, - ) + if created.is_empty() { + self.discard_spawned(spawned); + if created_workspace { + drop(workspace_lifecycle_guard); + let _ = self.close_workspace_at_revision_for_resource_effect(target_workspace); + } + anyhow::bail!("layout must contain at least one leaf"); + } + let active_pane = root.first_visible_pane(); + let screen_id = self.next_id(); + let notifications = self.surface_notifications(); + let delta = { + let mut state = self.state.lock().unwrap(); + let Some(workspace_index) = state.workspace_index(target_workspace) else { + drop(state); + self.discard_spawned(spawned); + anyhow::bail!("layout workspace disappeared"); + }; + for (_, pane) in panes { + state.insert_pane(pane); + } + stamp_pane_focus(self, &mut state, active_pane); + let screen = Screen { + id: screen_id, + public_id: ScreenPublicId::random()?, + name, + root, + active_pane, + zoomed_pane: None, + zellij_auto_layout: None, + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), + }; + let ws = &mut state.workspaces[workspace_index]; + ws.screens.push(screen); + ws.active_screen = ws.screens.len().saturating_sub(1); + let index = ws.active_screen; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::ScreenAdded, + screen_id, + ) + .expect("applied screen is present in tree snapshot"); + Self::rebuild_split_screen_index(&mut state); + TreeDelta { + kind: TreeDeltaKind::ScreenAdded, + workspace: target_workspace, + screen: Some(screen_id), + pane: None, + surface: None, + index: Some(index), + entity, + workspace_revision: None, + } + }; + let projection_result = self.with_state(|state| { + let (workspace, screen) = + state.screen_of(active_pane).expect("applied screen remains live"); + serde_json::json!({ + "workspace_id":state.workspaces[workspace].public_id, + "screen_id":state.workspaces[workspace].screens[screen].public_id, + }) + }); + if let Err(error) = + self.commit_ordinary_full_resource_projection("screen.layout.create", projection_result) + { + drop(workspace_lifecycle_guard); + let rollback = self.close_screen_for_resource_effect(screen_id); + if created_workspace { + let _ = self.close_workspace_at_revision_for_resource_effect(target_workspace); + } + return match rollback { + Ok(true) => Err(error.context("could not persist applied layout")), + Ok(false) => Err(error.context( + "could not persist applied layout and its screen disappeared during rollback", + )), + Err(rollback) => Err(error.context(format!( + "could not persist applied layout; rollback also failed: {rollback:#}" + ))), + }; + } + for surface in &spawned { + surface.activate_hosted_launch_stream()?; + } + self.emit(MuxEvent::TreeDelta(delta)); + self.emit(MuxEvent::LayoutChanged(screen_id)); + for surface in spawned { + self.reap_if_dead(&surface); + } + Ok(AppliedLayout { screen: screen_id, panes: created }) } - /// Set one in-process transactional split ratio without sharing the - /// control-client ownership namespace. - pub fn set_split_ratio_in_process_transaction_checked( + fn instantiate_layout( self: &Arc, - split: SplitId, - ratio: f32, - owner: u64, - transaction: u64, - ) -> Result<(), LayoutRatioError> { - self.set_split_ratio_inner( - split, - ratio, - Some((LayoutResizeOwner::InProcess(owner), transaction)), - false, - ) + layout: &LayoutSpec, + size: Option<(u16, u16)>, + workspace_key: &str, + panes: &mut Vec<(PaneId, Pane)>, + created: &mut Vec, + spawned: &mut Vec>, + ) -> anyhow::Result { + match layout { + LayoutSpec::Leaf(spec) => { + if spec.command.as_ref().is_some_and(|argv| argv.is_empty()) { + anyhow::bail!("leaf command must not be empty"); + } + let terminal_id = TerminalId::random()?; + let terminal_hex = terminal_id.to_hex(); + let mutation = WorkspaceMutation::local("cmux-tui-layout-terminal"); + let reservation = TerminalReservationRequest { + terminal_id, + mutation, + fingerprint: terminal_create_fingerprint( + workspace_key, + Some(&terminal_hex), + spec.command.as_deref(), + spec.cwd.as_deref(), + None, + size, + )?, + expected_generation: None, + expected_revision: None, + }; + let surface = self.spawn_surface_in_workspace_reserved( + workspace_key, + spec.cwd.clone(), + size, + spec.command.clone(), + reservation, + )?; + let (pane_id, pane) = self.make_pane(surface.id)?; + created.push(AppliedPane { pane: pane_id, surface: surface.id }); + panes.push((pane_id, pane)); + spawned.push(surface); + Ok(Node::Leaf(pane_id)) + } + LayoutSpec::Split { dir, ratio, a, b } => Ok(Node::Split { + id: self.next_id(), + dir: *dir, + ratio: clamp_split_ratio(*ratio), + a: Box::new(self.instantiate_layout( + a, + size, + workspace_key, + panes, + created, + spawned, + )?), + b: Box::new(self.instantiate_layout( + b, + size, + workspace_key, + panes, + created, + spawned, + )?), + }), + LayoutSpec::Stack { pane_count, expanded_index } => { + if *pane_count == 0 { + anyhow::bail!("stack must contain at least one pane"); + } + if *expanded_index >= *pane_count { + anyhow::bail!("stack expanded pane must be a member"); + } + let mut pane_ids = Vec::with_capacity(*pane_count); + for _ in 0..*pane_count { + let node = self.instantiate_layout( + &LayoutSpec::Leaf(LayoutLeafSpec { cwd: None, command: None }), + size, + workspace_key, + panes, + created, + spawned, + )?; + let Node::Leaf(pane_id) = node else { unreachable!() }; + pane_ids.push(pane_id); + } + let expanded = pane_ids[*expanded_index]; + Ok(Node::stack_with_expanded(pane_ids, expanded).expect("validated stack")) + } + } } - fn set_split_ratio_inner( - self: &Arc, - split: SplitId, - ratio: f32, - transaction: Option<(LayoutResizeOwner, u64)>, - tree_changed: bool, - ) -> Result<(), LayoutRatioError> { - let ratio = clamp_split_ratio(ratio); - let target = { - let state = self.state.lock().unwrap(); - let Some((workspace_index, screen_index, owner)) = - state.split_screens.get(&split).copied() - else { - return Err(LayoutRatioError::UnknownSplit { split }); - }; - if state - .workspaces - .get(workspace_index) - .and_then(|workspace| workspace.screens.get(screen_index)) - .is_none_or(|screen| screen.id != owner) - { - return Err(LayoutRatioError::UnknownSplit { split }); - } - let screen = &state.workspaces[workspace_index].screens[screen_index]; - if let Some(index) = screen - .layout_columns - .iter() - .position(|column| column.id == split) - .filter(|index| *index > 0) - { - let width_before = - screen.layout_columns[..index].iter().map(|column| column.width).sum::(); - let width = width_before * (1.0 - ratio) / ratio; - if !width.is_finite() - || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) + fn discard_spawned(&self, spawned: Vec>) { + if spawned.is_empty() { + return; + } + let hosted = spawned + .iter() + .filter_map(|surface| self.resource_terminal_host_identity(surface)) + .map(|identity| (identity.terminal_id, Some(identity.incarnation))) + .collect::>(); + let mut registry = self.workspace_registry.lock().unwrap(); + let close = Self::terminal_public_ids_for_hosted(®istry, &hosted).and_then( + |closed_public_ids| { + registry + .close_terminals_atomically( + &WorkspaceMutation::local("cmux-tui-layout-discard"), + &hosted, + ) + .map(|batch| (batch, closed_public_ids)) + }, + ); + let (batch, closed_public_ids) = match close { + Ok(result) => result, + Err(error) => { + // The transaction rolled back, so killing or dropping these + // surfaces would leave durable Running rows unreachable. Put + // every still-canonical terminal into its registry workspace + // while the same registry -> state writer fence is held. + let mut topology_changed = false; + let mut projection_errors = Vec::new(); { - return Err(LayoutRatioError::UnrepresentableViewportWidth { - split, - ratio, - width, - }); - } - if screen.layout_columns[index].width == width { - return Ok(()); + let mut state = self.state.lock().unwrap(); + for (terminal_id, _) in &hosted { + let terminal = match registry.terminal_record(terminal_id) { + Ok(Some(terminal)) + if terminal.lifecycle != TerminalLifecycle::Tombstoned => + { + terminal + } + Ok(_) => continue, + Err(projection_error) => { + projection_errors.push(format!( + "{terminal_id}: could not read canonical placement: {projection_error}" + )); + continue; + } + }; + match self.project_terminal_to_workspace_in_state( + &mut state, + terminal_id, + &terminal.workspace_key, + ) { + Ok((_, changed)) => topology_changed |= changed, + Err(projection_error) => projection_errors.push(format!( + "{terminal_id}: could not restore topology: {projection_error}" + )), + } + } } - } else { - let Some(current) = screen.root.split_ratio(split) else { - return Err(LayoutRatioError::UnknownSplit { split }); + drop(registry); + let projection_errors = if projection_errors.is_empty() { + String::new() + } else { + format!("; {}", projection_errors.join("; ")) }; - if current == ratio { - return Ok(()); + self.emit(MuxEvent::Status(format!( + "could not atomically close discarded terminals: {error}{projection_errors}" + ))); + if topology_changed { + self.emit(MuxEvent::TreeChanged); } + return; } - ( - screen.active_pane, - state - .resource_indexes - .split_ids - .get(&split) - .cloned() - .ok_or(LayoutRatioError::UnknownSplit { split })?, - ) }; - let selectors = self - .ordinary_pane_selectors(target.0) - .ok_or(LayoutRatioError::UnknownSplit { split })?; - let mut fields = Map::from_iter([ - ("split_id".into(), Value::String(target.1.to_string())), - ("ratio".into(), Value::from(ratio)), - ]); - if let Some((owner, transaction)) = transaction { - let (kind, owner) = match owner { - LayoutResizeOwner::ControlClient(owner) => ("control-client", owner), - LayoutResizeOwner::InProcess(owner) => ("in-process", owner), - }; - fields.insert("resize_owner_kind".into(), Value::String(kind.into())); - fields.insert("resize_owner".into(), Value::from(owner)); - fields.insert("resize_transaction".into(), Value::from(transaction)); + let removed = { + let mut state = self.state.lock().unwrap(); + let mut removed = Vec::new(); + for surface in &spawned { + removed.extend(remove_terminal_runtime_from_state(self, &mut state, surface).0); + } + removed + }; + if batch.closed != 0 { + self.emit_terminal_registry_changed(®istry, batch.revision); } - let commit = self - .commit_ordinary_topology_operation( - ResourceOperation::PaneSplitRatioSet, - selectors, - fields, - ) - .map_err(|error| { - self.emit(MuxEvent::Status(format!("could not persist split ratio: {error:#}"))); - LayoutRatioError::UnknownSplit { split } - })?; - if tree_changed { - self.emit(MuxEvent::TreeChanged); + drop(registry); + self.notify_terminal_exit_waiters(closed_public_ids); + for placement in removed { + self.purge_surface_side_tables(placement.id); } - if let Some(screen) = commit - .result - .get("screen") - .and_then(Value::as_str) - .and_then(|id| ScreenPublicId::parse(id.to_string()).ok()) - .and_then(|id| { - self.with_state(|state| state.resource_indexes.screens.get(&id).copied()) - }) - { - self.emit(MuxEvent::LayoutChanged(screen)); + for surface in spawned { + self.purge_terminal_runtime_side_tables(&surface); + if !surface.is_dead() { + self.terminate_terminal_runtime(&surface); + } } - Ok(()) - } - - /// Set the width of the horizontal viewport column containing `pane`. - pub fn set_viewport_pane_width(self: &Arc, pane: PaneId, width: f32) -> bool { - self.set_viewport_pane_width_checked(pane, width).is_ok() - } - - /// Set a viewport column width while preserving rejection details. - pub fn set_viewport_pane_width_checked( - self: &Arc, - pane: PaneId, - width: f32, - ) -> Result<(), ViewportWidthError> { - self.set_viewport_pane_width_inner(pane, width, None) - } - - /// Set a viewport column width as part of a client-scoped resize transaction. - pub fn set_viewport_pane_width_in_transaction( - self: &Arc, - pane: PaneId, - width: f32, - client: u64, - transaction: u64, - ) -> bool { - self.set_viewport_pane_width_in_transaction_checked(pane, width, client, transaction) - .is_ok() - } - - /// Set a transactional viewport width while preserving rejection details. - pub fn set_viewport_pane_width_in_transaction_checked( - self: &Arc, - pane: PaneId, - width: f32, - client: u64, - transaction: u64, - ) -> Result<(), ViewportWidthError> { - self.set_viewport_pane_width_inner( - pane, - width, - Some((LayoutResizeOwner::ControlClient(client), transaction)), - ) - } - - /// Set one in-process transactional viewport width without sharing the - /// control-client ownership namespace. - pub fn set_viewport_pane_width_in_process_transaction_checked( - self: &Arc, - pane: PaneId, - width: f32, - owner: u64, - transaction: u64, - ) -> Result<(), ViewportWidthError> { - self.set_viewport_pane_width_inner( - pane, - width, - Some((LayoutResizeOwner::InProcess(owner), transaction)), - ) } - fn set_viewport_pane_width_inner( - self: &Arc, - pane: PaneId, - width: f32, - transaction: Option<(LayoutResizeOwner, u64)>, - ) -> Result<(), ViewportWidthError> { - if !width.is_finite() - || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) - { - return Err(ViewportWidthError::OutOfRange { width }); - } - { - let state = self.state.lock().unwrap(); - let Some((workspace_index, screen_index)) = state.screen_of(pane) else { - return Err(ViewportWidthError::PaneNotResizable { pane }); - }; - let screen = &state.workspaces[workspace_index].screens[screen_index]; - if !screen.layout_columns_active() { - return Err(ViewportWidthError::PaneNotResizable { pane }); - } - let Some(column_index) = - screen.layout_columns.iter().position(|column| column.root.contains(pane)) - else { - return Err(ViewportWidthError::PaneNotResizable { pane }); - }; - if (screen.layout_columns[column_index].width - width).abs() < f32::EPSILON { - return Ok(()); - } - } - let selectors = self - .ordinary_pane_selectors(pane) - .ok_or(ViewportWidthError::PaneNotResizable { pane })?; - let mut fields = Map::from_iter([("width".into(), Value::from(width))]); - if let Some((owner, transaction)) = transaction { - let (kind, owner) = match owner { - LayoutResizeOwner::ControlClient(owner) => ("control-client", owner), - LayoutResizeOwner::InProcess(owner) => ("in-process", owner), - }; - fields.insert("resize_owner_kind".into(), Value::String(kind.into())); - fields.insert("resize_owner".into(), Value::from(owner)); - fields.insert("resize_transaction".into(), Value::from(transaction)); + #[allow(clippy::too_many_arguments)] + pub fn move_terminal_with_mutation( + &self, + terminal_id: &str, + workspace_key: &str, + expected_incarnation: Option<&str>, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + validate_terminal_hex(terminal_id, "invalid_terminal_id")?; + if let Some(incarnation) = expected_incarnation { + validate_terminal_hex(incarnation, "invalid_terminal_incarnation")?; } - let commit = self - .commit_ordinary_topology_operation( - ResourceOperation::PaneViewportWidthSet, - selectors, - fields, - ) - .map_err(|error| { - self.emit(MuxEvent::Status(format!( - "could not persist viewport pane width: {error:#}" - ))); - ViewportWidthError::PaneNotResizable { pane } - })?; - if let Some(screen) = commit - .result - .get("screen") - .and_then(Value::as_str) - .and_then(|id| ScreenPublicId::parse(id.to_string()).ok()) - .and_then(|id| { - self.with_state(|state| state.resource_indexes.screens.get(&id).copied()) - }) + let fingerprint = serde_json::json!({ + "op":"move-terminal", + "terminal_id":terminal_id, + "workspace_key":workspace_key, + "incarnation":expected_incarnation, + }); + let (terminal, terminal_revision, replayed, changed, placement, topology_changed) = { + let mut registry = self.workspace_registry.lock().unwrap(); + // Registry -> state is the global writer order. Holding both from + // canonical commit through projection prevents move B / move C + // from projecting C and then stale B, and serializes moves with a + // concurrent workspace close. + let mut state = self.state.lock().unwrap(); + if let Some(replay) = registry.replay_terminal(mutation, &fingerprint)? { + let terminal = registry + .terminal_record(terminal_id)? + .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; + let current_revision = registry.terminal_snapshot()?.revision; + let changed = replay.result["changed"].as_bool().unwrap_or(true); + #[cfg(test)] + if let Some(hook) = self.terminal_move_before_projection.lock().unwrap().clone() { + hook(); + } + let (placement, topology_changed) = + if terminal.lifecycle == TerminalLifecycle::Tombstoned { + (None, false) + } else { + self.project_terminal_to_workspace_in_state( + &mut state, + terminal_id, + &terminal.workspace_key, + )? + }; + (terminal, current_revision, true, changed, placement, topology_changed) + } else { + let snapshot = registry.terminal_snapshot()?; + let mut terminal = registry + .terminal_record(terminal_id)? + .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; + if terminal.lifecycle == TerminalLifecycle::Tombstoned { + anyhow::bail!("terminal is already closed"); + } + if let Some(expected) = expected_incarnation + && terminal.incarnation.as_deref() != Some(expected) + { + anyhow::bail!("terminal_incarnation_mismatch"); + } + let changed = terminal.workspace_key != workspace_key; + terminal.workspace_key = workspace_key.to_string(); + let commit = registry.commit_terminal( + mutation, + &fingerprint, + expected_generation, + expected_revision.or(Some(snapshot.revision)), + "terminal-moved", + &terminal, + &serde_json::json!({ + "terminal_id":terminal_id, + "workspace_key":workspace_key, + "incarnation":terminal.incarnation, + "state":terminal.lifecycle, + "changed":changed, + }), + )?; + self.emit_terminal_registry_changed(®istry, commit.revision); + #[cfg(test)] + if let Some(hook) = self.terminal_move_before_projection.lock().unwrap().clone() { + hook(); + } + let (placement, topology_changed) = self.project_terminal_to_workspace_in_state( + &mut state, + terminal_id, + &terminal.workspace_key, + )?; + (terminal, commit.revision, false, changed, placement, topology_changed) + } + }; + if placement.is_some() + && let Some(surface) = placement.and_then(|placement| self.surface(placement.surface)) { - self.emit(MuxEvent::LayoutChanged(screen)); + let _ = surface.persist_host_workspace(&terminal.workspace_key); } - Ok(()) + if topology_changed { + self.emit(MuxEvent::TreeChanged); + } + Ok(TerminalMoveResult { placement, terminal, terminal_revision, replayed, changed }) } - fn pane_navigation_layout(screen: &Screen, pane: PaneId, dir: Direction) -> LayoutResult { - const NAVIGATION_COLUMN_WIDTH: u16 = 10_000; - let column_area = Rect { x: 0, y: 0, width: NAVIGATION_COLUMN_WIDTH, height: 10_000 }; - if !screen.layout_columns_active() { - return layout_screen(&screen.root, column_area, Some(screen.active_pane)); - } - let Some(current_index) = - screen.layout_columns.iter().position(|column| column.root.contains(pane)) - else { - return layout_screen(&screen.root, column_area, Some(screen.active_pane)); - }; - let neighbor_index = match dir { - Direction::Up | Direction::Down => None, - Direction::Left => current_index.checked_sub(1), - Direction::Right => { - current_index.checked_add(1).filter(|index| *index < screen.layout_columns.len()) + fn project_terminal_to_workspace_in_state( + &self, + state: &mut State, + terminal_id: &str, + workspace_key: &str, + ) -> anyhow::Result<(Option, bool)> { + let Some(runtime) = self.catalog_terminal_by_host(state, terminal_id)? else { + // Still validate the in-memory projection while both writer locks + // are held; a missing destination indicates registry/state drift. + if state.workspaces.iter().all(|workspace| workspace.key != workspace_key) { + anyhow::bail!("unknown workspace key {workspace_key}"); } + return Ok((None, false)); }; - let Some(neighbor_index) = neighbor_index else { - return layout_screen( - &screen.layout_columns[current_index].root, - column_area, - Some(screen.active_pane), - ); + let surface = terminal_placement_for_runtime(state, &runtime); + let Some(surface) = surface else { + // A terminal with no view remains alive. Creating another view is + // an explicit `terminal.project` operation, not an implicit move. + return Ok((None, false)); }; - - let (left_index, right_index) = if neighbor_index < current_index { - (neighbor_index, current_index) + let active_at = self.next_active_at(); + let preserved_focus = current_focus_identity(state); + let destination = state + .workspaces + .iter() + .position(|workspace| workspace.key == workspace_key) + .ok_or_else(|| anyhow::anyhow!("unknown workspace key {workspace_key}"))?; + if let Some(current) = run_placement_for_surface(state, surface) + && current.workspace == state.workspaces[destination].id + { + return Ok((Some(current), false)); + } + let target_pane = if let Some(pane) = + state.workspaces[destination].active_screen_ref().map(|screen| screen.active_pane) + { + pane } else { - (current_index, neighbor_index) + let pane = self.next_id(); + let screen = self.next_id(); + state.insert_pane(Pane { + id: pane, + public_id: PanePublicId::random()?, + name: None, + tabs: Vec::new(), + active_tab: 0, + active_at, + // The projection preserves the user's existing focus + // identity below; this destination starts unfocused. + focused_at: 0, + }); + state.workspaces[destination].screens.push(Screen { + id: screen, + public_id: ScreenPublicId::random()?, + name: None, + root: Node::Leaf(pane), + active_pane: pane, + zoomed_pane: None, + zellij_auto_layout: Some(vec![pane]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), + }); + state.workspaces[destination].active_screen = 0; + pane }; - let mut result = LayoutResult { virtual_width: 20_000, ..Default::default() }; - for (index, x) in [(left_index, 0), (right_index, NAVIGATION_COLUMN_WIDTH)] { - let mut column = layout_screen( - &screen.layout_columns[index].root, - Rect { x, ..column_area }, - Some(screen.active_pane), - ); - result.panes.append(&mut column.panes); - result.stacked_headers.extend(column.stacked_headers); + if state.pane_of(surface).is_some() { + let (moved, topology_changed) = + move_tab_in_state(self, state, surface, target_pane, usize::MAX); + if !moved { + anyhow::bail!("terminal topology changed during move"); + } + if topology_changed { + Self::rebuild_split_screen_index(state); + } + } else { + let pane = state + .panes + .get_mut(&target_pane) + .ok_or_else(|| anyhow::anyhow!("destination pane disappeared"))?; + pane.tabs.push(surface); + pane.active_tab = pane.tabs.len() - 1; + pane.active_at = active_at; + state.resource_indexes.tab_pane.insert(surface, target_pane); + fence_layout_undo_for_tab_membership(state, &[target_pane]); } - result - } - - pub fn pane_neighbor(&self, pane: PaneId, dir: Direction) -> anyhow::Result> { - self.with_state(|state| { - let Some((wi, si)) = state.screen_of(pane) else { - anyhow::bail!("unknown pane {pane}"); - }; - let screen = &state.workspaces[wi].screens[si]; - let (dx, dy) = dir.delta(); - let layout = Self::pane_navigation_layout(screen, pane, dir); - Ok(layout.neighbor(pane, dx, dy)) - }) + restore_focus_identity(state, preserved_focus); + let placement = run_placement_for_surface(state, surface) + .ok_or_else(|| anyhow::anyhow!("terminal move did not produce a binding"))?; + Ok((Some(placement), true)) } - #[cfg(test)] - fn pane_focus_neighbor(&self, pane: PaneId, dir: Direction) -> anyhow::Result> { - self.with_state(|state| { - let Some((wi, si)) = state.screen_of(pane) else { - anyhow::bail!("unknown pane {pane}"); + /// Move an existing tab to `index` in `pane`. The surface is kept + /// alive; if moving it empties the source pane, that pane collapses + /// out of its split tree. + pub fn move_tab(self: &Arc, surface: SurfaceId, pane: PaneId, index: usize) -> bool { + if self.with_state(|state| { + let Some(source) = state.pane_of(surface) else { return false }; + if source != pane { + return false; + } + let Some(pane) = state.panes.get(&pane) else { return false }; + let Some(old_index) = pane.tabs.iter().position(|candidate| *candidate == surface) + else { + return false; }; - let screen = &state.workspaces[wi].screens[si]; - let (dx, dy) = dir.delta(); - let layout = Self::pane_navigation_layout(screen, pane, dir); - Ok(layout.neighbor_by_recency(pane, dx, dy, |candidate| { - state.panes.get(&candidate).map(|pane| pane.focused_at).unwrap_or_default() - })) - }) - } - - pub fn focus_direction( - self: &Arc, - pane: Option, - dir: Direction, - ) -> anyhow::Result { - let target = self.with_state(|state| pane.or_else(|| state.active_pane())); - let Some(target) = target else { - anyhow::bail!("no active pane"); - }; - let selectors = self - .ordinary_pane_selectors(target) - .with_context(|| format!("unknown pane {target}"))?; - let direction = match dir { - Direction::Left => "left", - Direction::Right => "right", - Direction::Up => "up", - Direction::Down => "down", - }; - let commit = self.commit_ordinary_topology_operation( - ResourceOperation::PaneFocusDirection, - selectors, - Map::from_iter([("direction".into(), Value::String(direction.into()))]), - )?; - let public_id = PanePublicId::parse( - commit.result["pane"] - .as_str() - .context("pane focus result omitted its pane id")? - .to_string(), - )?; - let next = self - .with_state(|state| state.resource_indexes.panes.get(&public_id).copied()) - .context("focused pane disappeared")?; - let viewed = self.with_state(Self::active_surface_in_state); - self.clear_viewed_notification(viewed); - self.emit(MuxEvent::TreeChanged); - Ok(next) - } - - pub fn swap_panes(self: &Arc, pane: PaneId, target: PaneId) -> bool { - if pane == target { + let final_index = if index > old_index { index.saturating_sub(1) } else { index } + .min(pane.tabs.len().saturating_sub(1)); + final_index == old_index + }) { return false; } - let Some(selectors) = self.ordinary_pane_selectors(pane) else { return false }; - let Some((other_workspace, other_screen, other_pane)) = self.with_state(|state| { - let first = state.screen_of(pane)?; - let second = state.screen_of(target)?; - if first != second { - return None; - } - Some(( - state.workspaces[second.0].public_id.to_string(), - state.workspaces[second.0].screens[second.1].public_id.to_string(), - state.resource_indexes.pane_ids.get(&target)?.to_string(), - )) - }) else { + let Some(selectors) = self.ordinary_tab_selectors(surface) else { return false }; + let Some((destination_workspace, destination_screen, destination_pane, changed_screen)) = + self.with_state(|state| { + let (workspace, screen) = state.screen_of(pane)?; + let source_pane = state.pane_of(surface)?; + let source_screen = (source_pane != pane + && state.panes.get(&source_pane)?.tabs.len() == 1) + .then(|| { + state.screen_of(source_pane).map(|(workspace, screen)| { + state.workspaces[workspace].screens[screen].id + }) + }) + .flatten(); + Some(( + state.workspaces[workspace].public_id.to_string(), + state.workspaces[workspace].screens[screen].public_id.to_string(), + state.resource_indexes.pane_ids.get(&pane)?.to_string(), + source_screen, + )) + }) + else { return false; }; let fields = Map::from_iter([ - ("other_workspace".into(), Value::String(other_workspace)), - ("other_screen".into(), Value::String(other_screen)), - ("other_pane".into(), Value::String(other_pane)), + ("destination_workspace".into(), Value::String(destination_workspace)), + ("destination_screen".into(), Value::String(destination_screen)), + ("destination_pane".into(), Value::String(destination_pane)), + ("index".into(), Value::from(u64::try_from(index).unwrap_or(u64::MAX))), ]); let Ok(commit) = - self.commit_ordinary_topology_operation(ResourceOperation::PaneSwap, selectors, fields) + self.commit_ordinary_topology_operation(ResourceOperation::TabMove, selectors, fields) else { return false; }; - self.emit_resource_topology_legacy_events(ResourceOperation::PaneSwap, &commit); + if let Some(surface) = self.surface(surface) + && let Some(workspace_key) = self.workspace_key_for_pane(pane) + { + let _ = surface.persist_host_workspace(&workspace_key); + } + self.emit_resource_topology_legacy_events(ResourceOperation::TabMove, &commit); + if let Some(screen) = changed_screen.filter(|source| { + self.with_state(|state| { + state + .workspaces + .iter() + .any(|workspace| workspace.screens.iter().any(|screen| screen.id == *source)) + }) + }) { + self.emit(MuxEvent::LayoutChanged(screen)); + } true } - pub fn zoom_pane( + /// Reorder a workspace. The active workspace follows the moved entry. + pub fn move_workspace(&self, workspace: WorkspaceId, index: usize) -> bool { + self.move_workspace_at_revision(workspace, index, None) + .map(|result| result.is_some_and(|(_, changed)| changed)) + .unwrap_or(false) + } + + pub fn move_workspace_at_revision( + &self, + workspace: WorkspaceId, + index: usize, + expected_revision: Option, + ) -> anyhow::Result> { + { + let state = self.state.lock().unwrap(); + let Some(old_index) = state.workspace_index(workspace) else { + return Ok(None); + }; + if let Some(expected) = expected_revision + && expected != state.workspace_revision + { + anyhow::bail!( + "workspace revision conflict: expected {expected}, current {}", + state.workspace_revision + ); + } + let new_index = if index > old_index { index.saturating_sub(1) } else { index }; + let new_index = new_index.min(state.workspaces.len().saturating_sub(1)); + if new_index == old_index { + return Ok(Some((state.workspace_revision, false))); + } + } + let mutation = WorkspaceMutation::local("cmux-tui"); + let result = self.move_workspace_with_mutation( + Some(workspace), + None, + index, + None, + expected_revision, + &mutation, + )?; + Ok(Some((result.revision, result.changed))) + } + + #[allow(clippy::too_many_arguments)] + pub fn move_workspace_with_mutation( + &self, + workspace: Option, + requested_key: Option<&str>, + index: usize, + expected_generation: Option<&str>, + expected_revision: Option, + mutation: &WorkspaceMutation, + ) -> anyhow::Result { + let fingerprint = serde_json::json!({ + "op": "move-workspace", + "workspace": workspace, + "key": requested_key, + "index": index, + }); + let notifications = self.surface_notifications(); + let mut registry = self.workspace_registry.lock().unwrap(); + if let Some(commit) = registry.replay(mutation, &fingerprint)? { + return workspace_mutation_result(&commit); + } + let (delta, result) = { + let mut state = self.state.lock().unwrap(); + Self::require_workspace_revision(&state, expected_revision)?; + let old_idx = resolve_workspace_index(&state, workspace, requested_key)?; + let workspace_id = state.workspaces[old_idx].id; + let key = state.workspaces[old_idx].key.clone(); + // Protocol v7 uses insertion-point semantics. Once the source is + // removed, insertion points to its right shift left by one. + let new_idx = if index > old_idx { index.saturating_sub(1) } else { index }; + let new_idx = new_idx.min(state.workspaces.len().saturating_sub(1)); + let changed = new_idx != old_idx; + let mut desired = self.registry_projection(&state); + let desired_workspace = desired.remove(old_idx); + desired.insert(new_idx, desired_workspace); + let desired_active_workspace = + state.workspaces.get(state.active_workspace).map(|workspace| &workspace.public_id); + let commit = registry.commit_with_active_workspace( + mutation, + &fingerprint, + expected_generation, + expected_revision, + "workspace-moved", + &key, + &desired, + desired_active_workspace, + &serde_json::json!({ + "workspace": workspace_id, + "key": key.clone(), + "index": new_idx, + "changed": changed, + }), + )?; + let resource_revision = registry.snapshot()?.resource_revision; + let active_id = state.workspaces.get(state.active_workspace).map(|ws| ws.id); + state.move_workspace(old_idx, new_idx); + state.active_workspace = active_id + .and_then(|id| state.workspace_index(id)) + .unwrap_or_else(|| state.workspaces.len().saturating_sub(1)); + Self::rebuild_split_screen_index(&mut state); + state.workspace_revision = commit.revision; + state.resource_revision = resource_revision; + let workspace_revision = commit.revision; + let entity = crate::server::tree_entity_json( + &state, + ¬ifications, + TreeDeltaKind::WorkspaceMoved, + workspace_id, + ) + .expect("moved workspace is present in tree snapshot"); + ( + TreeDelta { + kind: TreeDeltaKind::WorkspaceMoved, + workspace: workspace_id, + screen: None, + pane: None, + surface: None, + index: Some(new_idx), + entity, + workspace_revision: Some(workspace_revision), + }, + workspace_mutation_result(&commit)?, + ) + }; + self.emit_committed_workspace_delta(®istry, delta, false); + drop(registry); + self.publish_resource_event(); + Ok(result) + } + + /// Select a tab within a pane (default: the active pane) by index or + /// relative delta. + pub fn select_tab( self: &Arc, pane: Option, - mode: ZoomMode, - ) -> anyhow::Result { - let (target, next, changed) = self.with_state(|state| { - let target = pane.or_else(|| state.active_pane()).context("no active pane")?; - let (workspace, screen) = - state.screen_of(target).with_context(|| format!("unknown pane {target}"))?; - let current = state.workspaces[workspace].screens[screen].zoomed_pane; - let next = match mode { - ZoomMode::Toggle if current == Some(target) => None, - ZoomMode::Toggle | ZoomMode::On => Some(target), - ZoomMode::Off => None, + index: Option, + delta: Option, + ) { + let surface = { + let state = self.state.lock().unwrap(); + let Some(target) = pane.or_else(|| state.active_pane()) else { return }; + let Some(pane) = state.panes.get(&target) else { return }; + let len = pane.tabs.len(); + if len == 0 { + return; + } + let selected = if let Some(index) = index.filter(|index| *index < len) { + index + } else if let Some(delta) = delta { + ((pane.active_tab as isize + delta).rem_euclid(len as isize)) as usize + } else { + pane.active_tab }; - Ok::<_, anyhow::Error>((target, next, current != next)) - })?; - if !changed { - return Ok(ZoomState { pane: target, zoomed: next.is_some(), zoomed_pane: next }); + pane.tabs[selected] + }; + let Some(selectors) = self.ordinary_tab_selectors(surface) else { return }; + if self.commit_ordinary_tab_selection(selectors).is_err() { + return; } - let selectors = self - .ordinary_pane_selectors(target) - .with_context(|| format!("unknown pane {target}"))?; - let fields = match mode { - ZoomMode::Toggle => Map::new(), - ZoomMode::On => Map::from_iter([("enabled".into(), Value::Bool(true))]), - ZoomMode::Off => Map::from_iter([("enabled".into(), Value::Bool(false))]), + let viewed = self.with_state(Self::active_surface_in_state); + self.clear_viewed_notification(viewed); + self.emit(MuxEvent::TreeChanged); + } + + /// Select a screen in the active workspace by index or relative delta. + pub fn select_screen(self: &Arc, index: Option, delta: Option) { + let screen = { + let state = self.state.lock().unwrap(); + let active = state.active_workspace; + let Some(ws) = state.workspaces.get(active) else { return }; + let len = ws.screens.len(); + if len == 0 { + return; + } + let selected = if let Some(index) = index.filter(|index| *index < len) { + index + } else if let Some(delta) = delta { + ((ws.active_screen as isize + delta).rem_euclid(len as isize)) as usize + } else { + ws.active_screen + }; + ws.screens[selected].id }; - let commit = self.commit_ordinary_topology_operation( - ResourceOperation::PaneZoom, - selectors, - fields, - )?; - self.emit_resource_topology_legacy_events(ResourceOperation::PaneZoom, &commit); - let zoomed_pane = self.with_state(|state| { - state - .screen_of(target) - .and_then(|(workspace, screen)| { - state.workspaces.get(workspace)?.screens.get(screen) - }) - .and_then(|screen| screen.zoomed_pane) - }); - Ok(ZoomState { pane: target, zoomed: zoomed_pane.is_some(), zoomed_pane }) + let Some(selectors) = self.ordinary_screen_selectors(screen) else { return }; + if self + .commit_ordinary_topology_operation( + ResourceOperation::ScreenFocus, + selectors, + Map::new(), + ) + .is_err() + { + return; + } + let viewed = self.with_state(Self::active_surface_in_state); + self.clear_viewed_notification(viewed); + self.emit(MuxEvent::TreeChanged); } - /// Undo the latest structural layout transaction on `pane`'s screen. - /// - /// Transactions that created panes return a confirmation preview first. - /// The preview is read only. The caller must retry with its exact current - /// layout revision and `confirm_close=true`; structural or created-pane tab - /// membership changes advance that revision before the retry can commit. - pub fn undo_layout( - self: &Arc, - pane: PaneId, - expected_revision: Option, - confirm_close: bool, - ) -> anyhow::Result { - let (screen_id, current_revision, created_panes) = { + /// Select a workspace by index or relative delta. + pub fn select_workspace(self: &Arc, index: Option, delta: Option) { + let workspace = { let state = self.state.lock().unwrap(); - let Some((workspace_index, screen_index)) = state.screen_of(pane) else { - return Err(LayoutUndoError::Stale( - "layout undo target is no longer available".to_string(), - ) - .into()); - }; - let screen = &state.workspaces[workspace_index].screens[screen_index]; - let Some(entry) = screen.layout_undo.back() else { - return Err(LayoutUndoError::Unavailable.into()); - }; - if entry.after_revision != screen.layout_revision { - return Err(LayoutUndoError::Stale( - "layout changed since the last undoable action".to_string(), - ) - .into()); - } - if let Some(expected) = expected_revision - && expected != entry.after_revision - { - return Err(LayoutUndoError::Stale(format!( - "layout revision conflict: expected {expected}, current {}", - entry.after_revision - )) - .into()); - } - for created in &entry.created_panes { - if !state.panes.contains_key(created) { - return Err(LayoutUndoError::Stale(format!( - "created pane {created} disappeared before undo preview" - )) - .into()); - } + let len = state.workspaces.len(); + if len == 0 { + return; } - (screen.id, entry.after_revision, entry.created_panes.clone()) + let selected = if let Some(index) = index.filter(|index| *index < len) { + index + } else if let Some(delta) = delta { + ((state.active_workspace as isize + delta).rem_euclid(len as isize)) as usize + } else { + state.active_workspace + }; + state.workspaces[selected].id }; - if !created_panes.is_empty() && !confirm_close { - return Ok(LayoutUndoResult::ConfirmationRequired { - screen: screen_id, - revision: current_revision, - closes_panes: created_panes, - }); - } - if !created_panes.is_empty() && expected_revision.is_none() { - return Err(LayoutUndoError::Stale( - "confirmed layout undo requires the preview revision".to_string(), + let Some(selectors) = self.ordinary_workspace_selectors(workspace) else { return }; + if self + .commit_ordinary_topology_operation( + ResourceOperation::WorkspaceFocus, + selectors, + Map::new(), ) - .into()); + .is_err() + { + return; + } + let viewed = self.with_state(Self::active_surface_in_state); + self.clear_viewed_notification(viewed); + self.emit(MuxEvent::TreeChanged); + } +} + +fn persist_public_topology_result( + operation: &str, + result: &mut Value, + changes: &Value, +) -> anyhow::Result<()> { + let Some((resource, identity_field)) = public_topology_result_target(operation) else { + return Ok(()); + }; + let id = result + .get(identity_field) + .and_then(Value::as_str) + .with_context(|| format!("{operation} result omitted its {identity_field} identity"))?; + let value = changes + .as_array() + .context("public topology changes are not an array")? + .iter() + .rev() + .find(|change| { + change["kind"] == "upsert" + && change["resource"] == resource + && change["id"].as_str() == Some(id) + }) + .and_then(|change| change.get("value")) + .cloned() + .with_context(|| { + format!("{operation} changes omitted the committed {resource} value for {id}") + })?; + result + .as_object_mut() + .context("public topology result is not an object")? + .insert("public_value".to_string(), value); + Ok(()) +} + +fn public_topology_result_target(operation: &str) -> Option<(&'static str, &'static str)> { + match operation { + "workspace.rename" | "workspace.move" | "workspace.focus" | "workspace.layout.apply" => { + Some(("workspace", "workspace")) } + "screen.rename" | "screen.focus" | "screen.layout.undo" => Some(("screen", "screen")), + "pane.rename" + | "pane.focus" + | "pane.focus_direction" + | "pane.swap" + | "pane.zoom" + | "pane.split_ratio.set" + | "pane.viewport_width.set" => Some(("pane", "pane")), + "tab.rename" | "tab.move" | "tab.focus" => Some(("tab", "tab")), + _ => None, + } +} + +fn terminal_launch_spec(options: &SurfaceOptions) -> Value { + let cmux_env = options + .extra_env + .iter() + .map(|(key, _)| key.as_str()) + .filter(|key| { + matches!( + *key, + "CMUX_TUI_SOCKET" + | "CMUX_MUX_SOCKET" + | "CMUX_TUI_HOOK" + | "CMUX_TUI_SESSION_ID" + | "CMUX_TUI_TERMINAL_ID" + | "CMUX_SIDEBAR" + ) + }) + .collect::>(); + serde_json::json!({ + // This is diagnostic shape, not a respawn recipe. argv and cwd can + // both contain credentials and missing hosts are never recreated. + "command_present": options.command.is_some(), + "cwd_present": options.cwd.is_some(), + "term": options.term, + "cols": options.cols, + "rows": options.rows, + "scrollback": options.scrollback, + // Values are deliberately absent: launch environments routinely + // contain bearer credentials and SQLite is durable frontend state, + // not a secret store or a shell-respawn recipe. + "cmux_env": cmux_env, + }) +} + +/// Durable exactly-once metadata must distinguish retries without turning the +/// workspace registry into a second secret store. Command arguments, cwd, and +/// user-provided names can all contain credentials, so only their digest is +/// persisted alongside the non-secret routing identity. +fn terminal_create_fingerprint( + workspace_key: &str, + terminal_id: Option<&str>, + argv: Option<&[String]>, + cwd: Option<&str>, + name: Option<&str>, + size: Option<(u16, u16)>, +) -> anyhow::Result { + let request = serde_json::json!({ + "argv": argv, + "cwd": cwd, + "name": name, + "size": size, + }); + let digest = Sha256::digest(serde_json::to_vec(&request)?); + let request_sha256 = digest.iter().map(|byte| format!("{byte:02x}")).collect::(); + Ok(serde_json::json!({ + "op": "create-terminal", + "workspace_key": workspace_key, + "terminal_id": terminal_id, + "request_sha256": request_sha256, + })) +} + +fn terminal_lifecycle_name(lifecycle: TerminalLifecycle) -> &'static str { + match lifecycle { + TerminalLifecycle::Launching => "launching", + TerminalLifecycle::Adopting => "adopting", + TerminalLifecycle::Running => "running", + TerminalLifecycle::Exited => "exited", + TerminalLifecycle::Tombstoned => "tombstoned", + } +} + +fn run_placement_for_surface(state: &State, surface: SurfaceId) -> Option { + let pane = state.pane_of(surface)?; + let (workspace_index, screen_index) = state.screen_of(pane)?; + Some(RunPlacement { + surface, + pane, + screen: state.workspaces[workspace_index].screens[screen_index].id, + workspace: state.workspaces[workspace_index].id, + }) +} + +fn terminal_exit_snapshot_in_state( + registry: &WorkspaceRegistry, + state: &State, + terminal_id: &str, +) -> anyhow::Result { + let public_id = registry + .terminal_resource_id(terminal_id)? + .ok_or_else(|| anyhow::anyhow!("terminal {terminal_id} has no public resource id"))?; + let content_id = ContentPublicId::Terminal(public_id.clone()); + let topology = registry.resource_topology_snapshot()?; + let tab_ids = topology + .tabs + .iter() + .filter(|tab| tab.content_id == content_id) + .map(|tab| tab.public_id.clone()) + .collect::>(); + let surface = state.surface_by_content_public_id(&content_id); + let (cols, rows) = surface.map(|surface| surface.size()).unwrap_or((80, 24)); + let mut snapshot = serde_json::json!({ + "id": public_id, + "tab_id": tab_ids.first(), + "tab_ids": tab_ids, + "title": surface.map(|surface| surface.title()).unwrap_or_default(), + "cols": cols.max(1), + "rows": rows.max(1), + "running": false, + }); + if let Some(cwd) = surface.and_then(|surface| surface.spawn_cwd()) { + snapshot["cwd"] = serde_json::json!(cwd); + } + Ok(snapshot) +} - let selectors = self.ordinary_screen_selectors(screen_id).ok_or_else(|| { - LayoutUndoError::Stale("layout undo target is no longer available".to_string()) - })?; - let mut fields = Map::from_iter([("confirm_close".into(), Value::Bool(confirm_close))]); - fields.insert("expected_layout_revision".into(), Value::from(current_revision)); - let expected_resource_revision = if created_panes.is_empty() { - None - } else { - let registry = self.workspace_registry.lock().unwrap(); - let state = self.state.lock().unwrap(); - let Some((workspace_index, screen_index)) = state.screen_of(pane) else { - return Err(LayoutUndoError::Stale( - "layout undo target disappeared before confirmation".to_string(), - ) - .into()); - }; - let details = - layout_undo_confirmation_details(&state, ®istry, workspace_index, screen_index)?; - let token = details["confirmation_token"] - .as_str() - .context("layout undo confirmation omitted its token")?; - fields.insert("confirmation_token".into(), Value::String(token.to_string())); - Some(registry.resource_topology_snapshot()?.revision) - }; - let commit = self - .commit_resource_topology_operation( - ResourceOperation::ScreenLayoutUndo, - selectors, - fields, - expected_resource_revision, - &WorkspaceMutation::local("cmux-tui-layout-undo"), - ) - .map_err(|error| { - if error - .downcast_ref::() - .is_some_and(|error| error.code == "revision.conflict") - { - anyhow::Error::new(LayoutUndoError::Stale( - "layout revision conflict: resource topology changed before confirmed undo could commit" - .to_string(), - )) - } else { - error - } - })?; - let screen = commit - .result - .get("screen") - .and_then(Value::as_str) - .and_then(|id| ScreenPublicId::parse(id.to_string()).ok()) - .and_then(|id| { - self.with_state(|state| state.resource_indexes.screens.get(&id).copied()) - }) - .unwrap_or(screen_id); - let revision = self - .with_state(|state| { - state - .workspaces - .iter() - .flat_map(|workspace| workspace.screens.iter()) - .find(|candidate| candidate.id == screen) - .map(|screen| screen.layout_revision) - }) - .ok_or_else(|| { - LayoutUndoError::Stale( - "layout undo screen disappeared after the change committed".to_string(), - ) - })?; - Ok(LayoutUndoResult::Undone { screen, revision }) +type FocusIdentity = (WorkspaceId, ScreenId, PaneId); + +fn current_focus_identity(state: &State) -> Option { + let workspace = state.workspaces.get(state.active_workspace)?; + let screen = workspace.active_screen_ref()?; + Some((workspace.id, screen.id, screen.active_pane)) +} + +fn restore_focus_identity(state: &mut State, focus: Option) { + let Some((workspace_id, screen_id, pane_id)) = focus else { return }; + let Some(workspace_index) = state.workspace_index(workspace_id) else { return }; + state.active_workspace = workspace_index; + let Some(screen_index) = + state.workspaces[workspace_index].screens.iter().position(|screen| screen.id == screen_id) + else { + return; + }; + state.workspaces[workspace_index].active_screen = screen_index; + if state.workspaces[workspace_index].screens[screen_index].root.contains(pane_id) { + state.workspaces[workspace_index].screens[screen_index].active_pane = pane_id; } +} - fn undo_layout_with_confirmation_token_for_resource_effect( - &self, - pane: PaneId, - expected_revision: Option, - confirm_close: bool, - confirmation_token: Option<&str>, - ) -> anyhow::Result { - let (workspace, screen_id, preview) = { - let state = self.state.lock().unwrap(); - let Some((workspace_index, screen_index)) = state.screen_of(pane) else { - return Err(LayoutUndoError::Stale( - "layout undo target is no longer available".to_string(), - ) - .into()); - }; - let workspace = state.workspaces[workspace_index].id; - let screen_id = state.workspaces[workspace_index].screens[screen_index].id; - let entry = { - let screen = &state.workspaces[workspace_index].screens[screen_index]; - let Some(entry) = screen.layout_undo.back().cloned() else { - return Err(LayoutUndoError::Unavailable.into()); - }; - if entry.after_revision != screen.layout_revision { - return Err(LayoutUndoError::Stale( - "layout changed since the last undoable action".to_string(), - ) - .into()); - } - entry - }; - if let Some(expected) = expected_revision - && expected != entry.after_revision - { - return Err(LayoutUndoError::Stale(format!( - "layout revision conflict: expected {expected}, current {}", - entry.after_revision - )) - .into()); - } - if !entry.created_panes.is_empty() && !confirm_close { - for created in &entry.created_panes { - if !state.panes.contains_key(created) { - return Err(LayoutUndoError::Stale(format!( - "created pane {created} disappeared before undo preview" - )) - .into()); - } - } - return Ok(LayoutUndoResult::ConfirmationRequired { - screen: screen_id, - revision: entry.after_revision, - closes_panes: entry.created_panes, - }); - } - (workspace, screen_id, entry) - }; - if !preview.created_panes.is_empty() && expected_revision.is_none() { - return Err(LayoutUndoError::Stale( - "confirmed layout undo requires the preview revision".to_string(), - ) - .into()); - } - if preview.created_panes.is_empty() { - let revision = { - let mut state = self.state.lock().unwrap(); - let Some((workspace_index, screen_index)) = state.screen_of(pane) else { - return Err(LayoutUndoError::Stale( - "layout undo target disappeared before the change could commit".to_string(), - ) - .into()); - }; - let screen = &mut state.workspaces[workspace_index].screens[screen_index]; - let Some(entry) = screen.layout_undo.pop_back() else { - return Err( - LayoutUndoError::Stale("layout undo disappeared".to_string()).into() - ); - }; - if entry.after_revision != screen.layout_revision - || expected_revision.is_some_and(|expected| expected != entry.after_revision) - { - screen.layout_undo.push_back(entry); - return Err(LayoutUndoError::Stale( - "layout changed before undo could commit".to_string(), - ) - .into()); - } - let revision = screen.layout_revision.saturating_add(1); - screen.restore_layout_snapshot(entry.before); - screen.layout_revision = revision; - if let Some(previous) = screen.layout_undo.back_mut() { - previous.after_revision = revision; - previous.coalesce = None; - } - Self::rebuild_split_screen_index(&mut state); - revision - }; - self.emit(MuxEvent::TreeChanged); - self.emit(MuxEvent::LayoutChanged(screen_id)); - return Ok(LayoutUndoResult::Undone { screen: screen_id, revision }); - } +fn commit_terminal_transition( + registry: &mut WorkspaceRegistry, + event_kind: &str, + operation: &str, + terminal: &RegistryTerminal, +) -> anyhow::Result { + let mutation = WorkspaceMutation::local("cmux-tui-runtime"); + let commit = registry.commit_terminal( + &mutation, + &serde_json::json!({ + "op": operation, + "terminal_id": terminal.terminal_id, + "workspace_key": terminal.workspace_key, + "incarnation": terminal.incarnation, + "lifecycle": terminal.lifecycle, + }), + None, + None, + event_kind, + terminal, + &serde_json::json!({ + "terminal_id": terminal.terminal_id, + "workspace_key": terminal.workspace_key, + "incarnation": terminal.incarnation, + "state": terminal.lifecycle, + }), + )?; + Ok(commit.revision) +} + +/// Advance only renderer lifecycle fields from the latest durable row. This +/// deliberately re-reads under the registry writer mutex and uses the +/// terminal revision as a CAS: a GUI move committed while a host launch or +/// adoption was in flight can never be overwritten by a stale row clone. +fn commit_terminal_lifecycle( + registry: &mut WorkspaceRegistry, + event_kind: &str, + operation: &str, + terminal_id: &str, + lifecycle: TerminalLifecycle, + incarnation: Option<&str>, + exit: Option, +) -> anyhow::Result<(RegistryTerminal, u64)> { + let snapshot = registry.terminal_snapshot()?; + let mut terminal = registry + .terminal_record(terminal_id)? + .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; + terminal.lifecycle = lifecycle; + if let Some(incarnation) = incarnation { + terminal.incarnation = Some(incarnation.to_string()); + } + terminal.exit = exit; + let mutation = WorkspaceMutation::local("cmux-tui-runtime"); + let commit = registry.commit_terminal( + &mutation, + &serde_json::json!({ + "op": operation, + "terminal_id": terminal.terminal_id, + "incarnation": terminal.incarnation, + "lifecycle": terminal.lifecycle, + }), + Some(&snapshot.generation), + Some(snapshot.revision), + event_kind, + &terminal, + &serde_json::json!({ + "terminal_id": terminal.terminal_id, + "workspace_key": terminal.workspace_key, + "incarnation": terminal.incarnation, + "state": terminal.lifecycle, + }), + )?; + Ok((terminal, commit.revision)) +} - let lifecycle = self.workspace_lifecycle(workspace); - let _workspace_lifecycle = lifecycle.lock().unwrap(); - let notifications = self.surface_notifications(); - let registry = self.workspace_registry.lock().unwrap(); - let (removed, deltas, selection_resync, revision) = { - let mut state = self.state.lock().unwrap(); - let Some(workspace_index) = state.workspace_index(workspace) else { - return Err(LayoutUndoError::Stale( - "layout undo workspace is no longer available".to_string(), - ) - .into()); - }; - let Some(screen_index) = state.workspaces[workspace_index] - .screens - .iter() - .position(|screen| screen.id == screen_id) - else { - return Err(LayoutUndoError::Stale( - "layout undo screen is no longer available".to_string(), - ) - .into()); - }; - let entry = { - let screen = &state.workspaces[workspace_index].screens[screen_index]; - let Some(entry) = screen.layout_undo.back().cloned() else { - return Err( - LayoutUndoError::Stale("layout undo disappeared".to_string()).into() - ); - }; - if entry.after_revision != screen.layout_revision - || expected_revision != Some(entry.after_revision) - { - return Err(LayoutUndoError::Stale( - "layout changed before confirmed undo could commit".to_string(), - ) - .into()); - } - entry - }; - let mut remaining_history = - state.workspaces[workspace_index].screens[screen_index].layout_undo.clone(); - remaining_history.pop_back(); +#[cfg(test)] +fn commit_terminal_workspace( + registry: &mut WorkspaceRegistry, + terminal_id: &str, + workspace_key: &str, +) -> anyhow::Result { + let snapshot = registry.terminal_snapshot()?; + let mut terminal = registry + .terminal_record(terminal_id)? + .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; + if terminal.lifecycle == TerminalLifecycle::Tombstoned { + anyhow::bail!("terminal is already closed"); + } + terminal.workspace_key = workspace_key.to_string(); + let mutation = WorkspaceMutation::local("cmux-tui-runtime"); + let commit = registry.commit_terminal( + &mutation, + &serde_json::json!({ + "op":"move-terminal", + "terminal_id":terminal_id, + "workspace_key":workspace_key, + }), + Some(&snapshot.generation), + Some(snapshot.revision), + "terminal-moved", + &terminal, + &serde_json::json!({ + "terminal_id":terminal_id, + "workspace_key":workspace_key, + "incarnation":terminal.incarnation, + "state":terminal.lifecycle, + }), + )?; + Ok(commit.revision) +} - let mut before_panes = Vec::new(); - entry.before.root.pane_ids(&mut before_panes); - let before_panes = before_panes.into_iter().collect::>(); - let mut current_panes = Vec::new(); - state.workspaces[workspace_index].screens[screen_index] - .root - .pane_ids(&mut current_panes); - let expected_panes = before_panes - .iter() - .copied() - .chain(entry.created_panes.iter().copied()) - .collect::>(); - if current_panes.into_iter().collect::>() != expected_panes { - return Err(LayoutUndoError::Stale( - "screen panes changed since the action being undone".to_string(), - ) - .into()); - } - if let Some(expected_token) = confirmation_token { - let details = layout_undo_confirmation_details( - &state, - ®istry, - workspace_index, - screen_index, - )?; - if details["confirmation_token"].as_str() != Some(expected_token) { - return Err(anyhow::Error::new(ResourceError::new( - "confirmation.required", - "layout undo confirmation is stale", - details, - false, - ))); - } - } +#[cfg(unix)] +fn terminate_host_record( + record: crate::terminal_host_runtime::TerminalHostRecord, + record_path: std::path::PathBuf, +) -> bool { + let Ok(mut host) = crate::terminal_host_runtime::adopt_terminal_host(record, record_path) + else { + return false; + }; + let exit_path = host.exit_record_path(); + let exit = host.terminate_and_wait_for_exit(); + host.disconnect(); + let Ok(exit) = exit else { return false }; + acknowledge_exact_terminal_host_exit(&exit_path, &exit) +} - let selection_before = active_tree_selection(&state); - let mut tabs = Vec::new(); - let mut deltas = Vec::new(); - for created in &entry.created_panes { - let Some(pane) = state.panes.get(created) else { - return Err(LayoutUndoError::Stale(format!( - "created pane {created} disappeared before undo" - )) - .into()); - }; - tabs.extend(pane.tabs.iter().copied()); - if let Some(delta) = close_pane_delta(&state, ¬ifications, *created) { - deltas.push(delta); - } - } - let mut removed = Vec::new(); - for surface in tabs { - if let (Some(surface), _) = remove_surface(self, &mut state, surface) { - removed.push(surface); - } - } - let Some(screen_index) = state.workspaces[workspace_index] - .screens - .iter() - .position(|screen| screen.id == screen_id) - else { - return Err(LayoutUndoError::Stale( - "layout changed while undo was closing panes".to_string(), - ) - .into()); - }; - let screen = &mut state.workspaces[workspace_index].screens[screen_index]; - let revision = screen.layout_revision.max(entry.after_revision).saturating_add(1); - screen.restore_layout_snapshot(entry.before); - screen.layout_revision = revision; - screen.layout_undo = remaining_history; - if let Some(previous) = screen.layout_undo.back_mut() { - previous.after_revision = revision; - previous.coalesce = None; - } - Self::rebuild_split_screen_index(&mut state); - let selection_resync = selection_before != active_tree_selection(&state); - (removed, deltas, selection_resync, revision) - }; - drop(registry); +#[cfg(unix)] +fn acknowledge_exact_terminal_host_exit( + exit_path: &Path, + exit: &crate::terminal_host_runtime::TerminalHostExitRecord, +) -> bool { + match crate::terminal_host_runtime::acknowledge_terminal_host_exit_record(exit_path, exit) { + Ok(true) => true, + Ok(false) => !exit_path.exists(), + Err(_) => false, + } +} - for surface in removed { - self.purge_surface_side_tables(surface.id); - if surface.kind() == SurfaceKind::Browser { - surface.kill(); +#[cfg(unix)] +fn terminal_host_record_liveness( + record_path: &Path, + record: &crate::terminal_host_runtime::TerminalHostRecord, +) -> TerminalHostLiveness { + crate::terminal_host_runtime::terminal_host_record_liveness(record_path, record) + .unwrap_or(TerminalHostLiveness::Indeterminate) +} + +/// Ask a host to terminate first; if its admin socket is unavailable, remove +/// discovery artifacts only when the process-start nonce positively proves +/// this exact incarnation is dead. `false` means the live/ambiguous record is +/// deliberately retained for a later retry. +#[cfg(unix)] +fn cleanup_terminal_host_record( + record: &crate::terminal_host_runtime::TerminalHostRecord, + record_path: &Path, +) -> bool { + match terminal_host_record_liveness(record_path, record) { + TerminalHostLiveness::Dead => { + match crate::terminal_host_runtime::remove_stale_terminal_host_record( + record_path, + record, + ) { + Ok(removed) => removed, + // Positive nonce/PID death proof remains authoritative when + // the host already removed its own record between probe and + // compare-delete. + Err(_) if !record_path.exists() => true, + Err(_) => false, } } - if deltas.is_empty() { - self.emit(MuxEvent::TreeChanged); - } else { - for delta in deltas { - self.emit_tree_delta(delta, selection_resync); + TerminalHostLiveness::Live | TerminalHostLiveness::Indeterminate => { + terminate_host_record(record.clone(), record_path.to_path_buf()) + } + } +} + +fn bounded_shutdown_fanout( + items: &[T], + deadline: Instant, + operation: impl Fn(&T, Instant) + Sync, +) -> usize { + bounded_shutdown_fanout_with_clock(items, deadline, Instant::now, operation) +} + +fn bounded_shutdown_fanout_with_clock( + items: &[T], + deadline: Instant, + now: impl Fn() -> Instant + Sync, + operation: impl Fn(&T, Instant) + Sync, +) -> usize { + if items.is_empty() { + return 0; + } + let next = AtomicUsize::new(0); + let workers = items.len().min(SHUTDOWN_FANOUT_WORKERS); + std::thread::scope(|scope| { + for worker in 0..workers { + let next = &next; + let now = &now; + let operation = &operation; + let spawned = std::thread::Builder::new() + .name(format!("shutdown-owner-{worker}")) + .spawn_scoped(scope, move || { + loop { + if now() >= deadline { + break; + } + let index = next.fetch_add(1, Ordering::Relaxed); + let Some(item) = items.get(index) else { break }; + operation(item, deadline); + } + }); + if spawned.is_err() { + break; } } - self.emit(MuxEvent::LayoutChanged(screen_id)); - Ok(LayoutUndoResult::Undone { screen: screen_id, revision }) + }); + next.load(Ordering::Relaxed).min(items.len()) +} + +fn insert_surface_checked( + mux: &Mux, + state: &mut State, + surface: Arc, +) -> anyhow::Result<()> { + validate_surface_insertion(state, &surface)?; + if state + .surfaces + .len() + .saturating_add(mux.shutdown_owners.len()) + .saturating_add(mux.surface_owner_reservations.load(Ordering::Acquire)) + >= mux.shutdown_owner_capacity() + { + anyhow::bail!("surface_owner_capacity_exhausted"); } + register_terminal_runtime_checked(state, &surface)?; + state.surfaces.insert(surface.id, surface); + Ok(()) +} - pub fn apply_layout( - self: &Arc, - workspace: Option, - name: Option, - layout: &LayoutSpec, - size: Option<(u16, u16)>, - ) -> anyhow::Result { - let target_workspace = { - let state = self.state.lock().unwrap(); - if let Some(id) = workspace - && !state.workspaces.iter().any(|ws| ws.id == id) - { - anyhow::bail!("unknown workspace {id}"); - } - workspace.or_else(|| state.workspaces.get(state.active_workspace).map(|ws| ws.id)) - }; - let (target_workspace, created_workspace) = match target_workspace { - Some(workspace) => (workspace, false), - None => ( - self.create_empty_workspace_for_resource_effect( - None, - None, - WorkspacePublicId::random()?, - &WorkspaceMutation::local("cmux-tui-layout-workspace"), - )? - .workspace, - true, - ), - }; - let workspace_lifecycle = self.workspace_lifecycle(target_workspace); - let workspace_lifecycle_guard = workspace_lifecycle.lock().unwrap(); - let workspace_key = self - .state - .lock() - .unwrap() - .workspace_by_id(target_workspace) - .map(|workspace| workspace.key.clone()) - .ok_or_else(|| anyhow::anyhow!("layout workspace disappeared"))?; - #[cfg(test)] - if let Some(hook) = self.layout_apply_after_workspace_reservation.lock().unwrap().clone() { - hook(); - } +fn insert_reserved_surface_checked( + mux: &Mux, + state: &mut State, + surface: Arc, + reservation: &mut SurfaceOwnerReservation<'_>, +) -> anyhow::Result<()> { + validate_surface_insertion(state, &surface)?; + if state + .surfaces + .len() + .saturating_add(mux.shutdown_owners.len()) + .saturating_add(mux.surface_owner_reservations.load(Ordering::Acquire)) + > mux.shutdown_owner_capacity() + { + anyhow::bail!("surface_owner_capacity_exhausted"); + } + register_terminal_runtime_checked(state, &surface)?; + state.surfaces.insert(surface.id, surface); + reservation.release(); + Ok(()) +} - let mut created = Vec::new(); - let mut panes = Vec::new(); - let mut spawned = Vec::new(); - let root = match self.instantiate_layout( - layout, - size, - &workspace_key, - &mut panes, - &mut created, - &mut spawned, - ) { - Ok(root) => root, - Err(err) => { - self.discard_spawned(spawned); - if created_workspace { - drop(workspace_lifecycle_guard); - let _ = self.close_workspace_at_revision_for_resource_effect(target_workspace); - } - return Err(err); - } - }; - if created.is_empty() { - self.discard_spawned(spawned); - if created_workspace { - drop(workspace_lifecycle_guard); - let _ = self.close_workspace_at_revision_for_resource_effect(target_workspace); - } - anyhow::bail!("layout must contain at least one leaf"); +fn insert_surface_with_active_reservation_checked( + mux: &Mux, + state: &mut State, + surface: Arc, +) -> anyhow::Result<()> { + validate_surface_insertion(state, &surface)?; + if state + .surfaces + .len() + .saturating_add(mux.shutdown_owners.len()) + .saturating_add(mux.surface_owner_reservations.load(Ordering::Acquire)) + > mux.shutdown_owner_capacity() + { + anyhow::bail!("surface_owner_capacity_exhausted"); + } + register_terminal_runtime_checked(state, &surface)?; + state.surfaces.insert(surface.id, surface); + Ok(()) +} + +fn validate_surface_insertion(state: &State, surface: &Surface) -> anyhow::Result<()> { + if state.surfaces.contains_key(&surface.id) { + anyhow::bail!("duplicate_surface_id"); + } + if let Some(expected_tab) = state.resource_indexes.tab_ids.get(&surface.id) { + let expected_content = state + .resource_indexes + .content_ids + .get(&surface.id) + .ok_or_else(|| anyhow::anyhow!("reserved tab has no content identity"))?; + let actual = surface + .resource_identity() + .ok_or_else(|| anyhow::anyhow!("public tab slot received auxiliary content"))?; + if &actual.tab_id != expected_tab || &actual.content_id != expected_content { + anyhow::bail!("surface resource identity does not match its reserved tab slot"); } - let active_pane = root.first_visible_pane(); - let screen_id = self.next_id(); - let notifications = self.surface_notifications(); - let delta = { - let mut state = self.state.lock().unwrap(); - let Some(workspace_index) = state.workspace_index(target_workspace) else { - drop(state); - self.discard_spawned(spawned); - anyhow::bail!("layout workspace disappeared"); - }; - for (_, pane) in panes { - state.insert_pane(pane); - } - stamp_pane_focus(self, &mut state, active_pane); - let screen = Screen { - id: screen_id, - public_id: ScreenPublicId::random()?, - name, - root, - active_pane, - zoomed_pane: None, - zellij_auto_layout: None, - viewport_splits: Default::default(), - viewport_base_width: None, - layout_columns: Vec::new(), - layout_revision: 0, - layout_undo: Default::default(), - }; - let ws = &mut state.workspaces[workspace_index]; - ws.screens.push(screen); - ws.active_screen = ws.screens.len().saturating_sub(1); - let index = ws.active_screen; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::ScreenAdded, - screen_id, - ) - .expect("applied screen is present in tree snapshot"); - Self::rebuild_split_screen_index(&mut state); - TreeDelta { - kind: TreeDeltaKind::ScreenAdded, - workspace: target_workspace, - screen: Some(screen_id), - pane: None, - surface: None, - index: Some(index), - entity, - workspace_revision: None, - } - }; - let projection_result = self.with_state(|state| { - let (workspace, screen) = - state.screen_of(active_pane).expect("applied screen remains live"); - serde_json::json!({ - "workspace_id":state.workspaces[workspace].public_id, - "screen_id":state.workspaces[workspace].screens[screen].public_id, - }) - }); - if let Err(error) = - self.commit_ordinary_full_resource_projection("screen.layout.create", projection_result) + } else if let Some(identity) = surface.resource_identity() { + if state.resource_indexes.tabs.contains_key(&identity.tab_id) { + anyhow::bail!("duplicate_tab_id"); + } + if matches!(identity.content_id, ContentPublicId::Browser(_)) + && state.resource_indexes.content_placements.contains_key(&identity.content_id) { - drop(workspace_lifecycle_guard); - let rollback = self.close_screen_for_resource_effect(screen_id); - if created_workspace { - let _ = self.close_workspace_at_revision_for_resource_effect(target_workspace); + anyhow::bail!("duplicate_content_id"); + } + } + if let Some(identity) = surface.resource_identity() + && let ContentPublicId::Terminal(terminal_id) = &identity.content_id + { + anyhow::ensure!( + surface.terminal_public_id() == Some(terminal_id), + "terminal placement identity does not match its runtime" + ); + } + Ok(()) +} + +fn insert_terminal_runtime_checked(state: &mut State, surface: Arc) -> anyhow::Result<()> { + anyhow::ensure!(surface.kind() == SurfaceKind::Pty, "terminal catalog requires a PTY"); + anyhow::ensure!( + surface.resource_identity().is_none(), + "unplaced terminal runtime cannot carry a tab identity" + ); + register_terminal_runtime_checked(state, &surface) +} + +fn insert_restored_terminal_runtime_checked( + mux: &Mux, + state: &mut State, + surface: Arc, +) -> anyhow::Result<()> { + let terminal_id = surface + .terminal_public_id() + .cloned() + .context("restored terminal omitted its public content identity")?; + let content_id = ContentPublicId::Terminal(terminal_id); + let placements = state.placements_of_content(&content_id).to_vec(); + if placements.is_empty() { + return insert_terminal_runtime_checked(state, surface); + } + + anyhow::ensure!( + placements.contains(&surface.id), + "restored terminal runtime is not one of its durable placements" + ); + insert_surface_with_active_reservation_checked(mux, state, surface.clone())?; + for placement in placements.into_iter().filter(|placement| *placement != surface.id) { + anyhow::ensure!( + !state.surfaces.contains_key(&placement), + "restored terminal placement is already materialized" + ); + let tab_id = state + .resource_indexes + .tab_ids + .get(&placement) + .cloned() + .context("restored terminal placement has no tab identity")?; + let projected = surface + .project_terminal(placement, TabResourceIdentity::new(tab_id, content_id.clone()))?; + insert_surface_checked(mux, state, projected)?; + } + Ok(()) +} + +fn register_terminal_runtime_checked( + state: &mut State, + surface: &Arc, +) -> anyhow::Result<()> { + let Some(terminal_id) = surface.terminal_public_id() else { + return Ok(()); + }; + let runtime_id = surface + .terminal_runtime_id() + .context("terminal content identity requires a PTY runtime")?; + if let Some(existing) = state.terminal_catalog_by_runtime.get(&runtime_id) { + anyhow::ensure!(existing == terminal_id, "terminal runtime has two content identities"); + } + if let Some(existing) = state.terminal_catalog.get(terminal_id) { + anyhow::ensure!( + existing.shares_terminal_runtime(surface), + "terminal content identity points at two runtimes" + ); + } else { + if let Some(identity) = surface.terminal_host_identity() { + for existing in state.terminal_catalog.values().filter(|existing| { + existing + .terminal_host_identity() + .is_some_and(|candidate| candidate.terminal_id == identity.terminal_id) + }) { + anyhow::ensure!(existing.shares_terminal_runtime(surface), "duplicate_terminal_id"); } - return match rollback { - Ok(true) => Err(error.context("could not persist applied layout")), - Ok(false) => Err(error.context( - "could not persist applied layout and its screen disappeared during rollback", - )), - Err(rollback) => Err(error.context(format!( - "could not persist applied layout; rollback also failed: {rollback:#}" - ))), - }; - } - for surface in &spawned { - surface.activate_hosted_launch_stream()?; } - self.emit(MuxEvent::TreeDelta(delta)); - self.emit(MuxEvent::LayoutChanged(screen_id)); - for surface in spawned { - self.reap_if_dead(&surface); + state.terminal_catalog.insert(terminal_id.clone(), surface.clone()); + } + state.terminal_catalog_by_runtime.insert(runtime_id, terminal_id.clone()); + Ok(()) +} + +/// Remove a terminal catalog owner and every view that projects it. Durable +/// exit and explicit close share this path so multiview teardown cannot leave +/// a reverse catalog entry or a secondary placement behind. The runtime is +/// optional because restored topology exists before host adoption. +fn remove_terminal_content_from_state( + mux: &Mux, + state: &mut State, + terminal_id: &TerminalPublicId, +) -> (Option>, Vec>, bool) { + let runtime = state.terminal_catalog.remove(terminal_id); + if let Some(runtime_id) = runtime.as_ref().and_then(|runtime| runtime.terminal_runtime_id()) { + state.terminal_catalog_by_runtime.remove(&runtime_id); + } + let mut targets = + state.placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())).to_vec(); + if let Some(runtime_id) = runtime.as_ref().and_then(|runtime| runtime.terminal_runtime_id()) { + targets.extend(state.surfaces.iter().filter_map(|(placement, candidate)| { + (candidate.terminal_runtime_id() == Some(runtime_id)).then_some(*placement) + })); + } + targets.sort_unstable(); + targets.dedup(); + let mut removed = Vec::with_capacity(targets.len()); + let mut split_index_dirty = false; + for target in targets { + let (candidate, topology_changed) = remove_surface(mux, state, target); + split_index_dirty |= topology_changed; + if let Some(candidate) = candidate { + removed.push(candidate); } - Ok(AppliedLayout { screen: screen_id, panes: created }) } + if split_index_dirty { + Mux::rebuild_split_screen_index(state); + } + (runtime, removed, split_index_dirty) +} - fn instantiate_layout( - self: &Arc, - layout: &LayoutSpec, - size: Option<(u16, u16)>, - workspace_key: &str, - panes: &mut Vec<(PaneId, Pane)>, - created: &mut Vec, - spawned: &mut Vec>, - ) -> anyhow::Result { - match layout { - LayoutSpec::Leaf(spec) => { - if spec.command.as_ref().is_some_and(|argv| argv.is_empty()) { - anyhow::bail!("leaf command must not be empty"); - } - let terminal_id = TerminalId::random()?; - let terminal_hex = terminal_id.to_hex(); - let mutation = WorkspaceMutation::local("cmux-tui-layout-terminal"); - let reservation = TerminalReservationRequest { - terminal_id, - mutation, - fingerprint: terminal_create_fingerprint( - workspace_key, - Some(&terminal_hex), - spec.command.as_deref(), - spec.cwd.as_deref(), - None, - size, - )?, - expected_generation: None, - expected_revision: None, - }; - let surface = self.spawn_surface_in_workspace_reserved( - workspace_key, - spec.cwd.clone(), - size, - spec.command.clone(), - reservation, - )?; - let (pane_id, pane) = self.make_pane(surface.id)?; - created.push(AppliedPane { pane: pane_id, surface: surface.id }); - panes.push((pane_id, pane)); - spawned.push(surface); - Ok(Node::Leaf(pane_id)) - } - LayoutSpec::Split { dir, ratio, a, b } => Ok(Node::Split { - id: self.next_id(), - dir: *dir, - ratio: clamp_split_ratio(*ratio), - a: Box::new(self.instantiate_layout( - a, - size, - workspace_key, - panes, - created, - spawned, - )?), - b: Box::new(self.instantiate_layout( - b, - size, - workspace_key, - panes, - created, - spawned, - )?), - }), - LayoutSpec::Stack { pane_count, expanded_index } => { - if *pane_count == 0 { - anyhow::bail!("stack must contain at least one pane"); - } - if *expanded_index >= *pane_count { - anyhow::bail!("stack expanded pane must be a member"); - } - let mut pane_ids = Vec::with_capacity(*pane_count); - for _ in 0..*pane_count { - let node = self.instantiate_layout( - &LayoutSpec::Leaf(LayoutLeafSpec { cwd: None, command: None }), - size, - workspace_key, - panes, - created, - spawned, - )?; - let Node::Leaf(pane_id) = node else { unreachable!() }; - pane_ids.push(pane_id); - } - let expanded = pane_ids[*expanded_index]; - Ok(Node::stack_with_expanded(pane_ids, expanded).expect("validated stack")) - } +/// Remove one known terminal runtime and every placement that projects it. +/// Ordinary topology removal must leave the catalog owner alive so a terminal +/// can have zero views and be projected again later. +fn remove_terminal_runtime_from_state( + mux: &Mux, + state: &mut State, + runtime: &Surface, +) -> (Vec>, bool) { + let Some(terminal_id) = runtime.terminal_public_id().cloned() else { + return (Vec::new(), false); + }; + if !state + .terminal_catalog + .get(&terminal_id) + .is_some_and(|catalogued| catalogued.shares_terminal_runtime(runtime)) + { + return (Vec::new(), false); + } + let (_, removed, split_index_dirty) = + remove_terminal_content_from_state(mux, state, &terminal_id); + (removed, split_index_dirty) +} + +fn validate_terminal_hex(value: &str, error: &'static str) -> anyhow::Result<()> { + if TerminalId::from_hex(value).is_none() { + anyhow::bail!(error); + } + Ok(()) +} + +fn unique_terminal_match( + terminal_id: &str, + identities: impl IntoIterator, +) -> anyhow::Result> { + let mut found = None; + for (value, identity) in identities { + if identity.terminal_id != terminal_id { + continue; } + anyhow::ensure!(found.is_none(), "duplicate_terminal_id"); + found = Some((value, identity)); } + Ok(found) +} + +/// Return one materialized view of `runtime`. The public reverse index is the +/// steady-state fast path. The runtime scan also admits a newly-created view +/// before it has been inserted into a pane: creation must bind that reserved +/// tab exactly once, while a detached zero-view terminal has no entry in +/// `state.surfaces` and therefore remains detached. +fn terminal_placement_for_runtime(state: &State, runtime: &Surface) -> Option { + let terminal_id = runtime.terminal_public_id()?; + state + .placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())) + .iter() + .copied() + .find(|placement| { + state + .surfaces + .get(placement) + .is_some_and(|surface| surface.shares_terminal_runtime(runtime)) + }) + .or_else(|| { + state + .surfaces + .iter() + .filter_map(|(placement, surface)| { + (surface.shares_terminal_runtime(runtime) + && surface.resource_identity().is_some()) + .then_some(*placement) + }) + .min() + }) +} + +/// Return one representative for each content runtime plus every nonterminal +/// surface. Runtime-wide mutations must not repeat host I/O or render work for +/// every terminal view, and catalog-only terminals still need those updates. +fn unique_surface_runtimes(state: &State) -> Vec> { + let mut seen_terminals = HashSet::new(); + state + .terminal_catalog + .values() + .chain(state.surfaces.values()) + .filter(|surface| { + surface.terminal_runtime_id().is_none_or(|runtime| seen_terminals.insert(runtime)) + }) + .cloned() + .collect() +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} - fn discard_spawned(&self, spawned: Vec>) { - if spawned.is_empty() { - return; - } - let hosted = spawned - .iter() - .filter_map(|surface| self.resource_terminal_host_identity(surface)) - .map(|identity| (identity.terminal_id, Some(identity.incarnation))) - .collect::>(); - let mut registry = self.workspace_registry.lock().unwrap(); - let close = Self::terminal_public_ids_for_hosted(®istry, &hosted).and_then( - |closed_public_ids| { - registry - .close_terminals_atomically( - &WorkspaceMutation::local("cmux-tui-layout-discard"), - &hosted, - ) - .map(|batch| (batch, closed_public_ids)) - }, - ); - let (batch, closed_public_ids) = match close { - Ok(result) => result, - Err(error) => { - // The transaction rolled back, so killing or dropping these - // surfaces would leave durable Running rows unreachable. Put - // every still-canonical terminal into its registry workspace - // while the same registry -> state writer fence is held. - let mut topology_changed = false; - let mut projection_errors = Vec::new(); - { - let mut state = self.state.lock().unwrap(); - for (terminal_id, _) in &hosted { - let terminal = match registry.terminal_record(terminal_id) { - Ok(Some(terminal)) - if terminal.lifecycle != TerminalLifecycle::Tombstoned => - { - terminal - } - Ok(_) => continue, - Err(projection_error) => { - projection_errors.push(format!( - "{terminal_id}: could not read canonical placement: {projection_error}" - )); - continue; - } - }; - match self.project_terminal_to_workspace_in_state( - &mut state, - terminal_id, - &terminal.workspace_key, - ) { - Ok((_, changed)) => topology_changed |= changed, - Err(projection_error) => projection_errors.push(format!( - "{terminal_id}: could not restore topology: {projection_error}" - )), - } - } - } - drop(registry); - let projection_errors = if projection_errors.is_empty() { - String::new() - } else { - format!("; {}", projection_errors.join("; ")) - }; - self.emit(MuxEvent::Status(format!( - "could not atomically close discarded terminals: {error}{projection_errors}" - ))); - if topology_changed { - self.emit(MuxEvent::TreeChanged); - } - return; - } - }; - let removed = { - let mut state = self.state.lock().unwrap(); - let mut removed = Vec::new(); - for surface in &spawned { - removed.extend(remove_terminal_runtime_from_state(self, &mut state, surface).0); +fn sidebar_retry_delay(failures: u32) -> Duration { + let shift = failures.saturating_sub(1).min(5); + Duration::from_secs(1u64 << shift) +} + +impl Drop for Mux { + fn drop(&mut self) { + self.shutdown_owner_reconciler.stop(); + if let Ok(watchers) = self.shutdown_request_watchers.get_mut() { + for watcher in watchers.drain(..).filter_map(|watcher| watcher.upgrade()) { + ShutdownRequestWatch { inner: watcher }.cancel(); } - removed - }; - if batch.closed != 0 { - self.emit_terminal_registry_changed(®istry, batch.revision); - } - drop(registry); - self.notify_terminal_exit_waiters(closed_public_ids); - for placement in removed { - self.purge_surface_side_tables(placement.id); } - for surface in spawned { - self.purge_terminal_runtime_side_tables(&surface); - if !surface.is_dead() { - surface.kill(); + if let Ok(state) = self.state.get_mut() { + let deadline = Instant::now() + TERMINAL_READER_SHUTDOWN_TIMEOUT; + for surface in unique_surface_runtimes(state) { + let _ = surface.shutdown_for_daemon(deadline); } } + self.journal_kernel.shutdown(); + if let Some(runtime) = self.browser_runtime.take_on_drop() { + runtime.shutdown(); + } } +} - #[allow(clippy::too_many_arguments)] - pub fn move_terminal_with_mutation( - &self, - terminal_id: &str, - workspace_key: &str, - expected_incarnation: Option<&str>, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - validate_terminal_hex(terminal_id, "invalid_terminal_id")?; - if let Some(incarnation) = expected_incarnation { - validate_terminal_hex(incarnation, "invalid_terminal_incarnation")?; - } - let fingerprint = serde_json::json!({ - "op":"move-terminal", - "terminal_id":terminal_id, - "workspace_key":workspace_key, - "incarnation":expected_incarnation, - }); - let (terminal, terminal_revision, replayed, changed, placement, topology_changed) = { - let mut registry = self.workspace_registry.lock().unwrap(); - // Registry -> state is the global writer order. Holding both from - // canonical commit through projection prevents move B / move C - // from projecting C and then stale B, and serializes moves with a - // concurrent workspace close. - let mut state = self.state.lock().unwrap(); - if let Some(replay) = registry.replay_terminal(mutation, &fingerprint)? { - let terminal = registry - .terminal_record(terminal_id)? - .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; - let current_revision = registry.terminal_snapshot()?.revision; - let changed = replay.result["changed"].as_bool().unwrap_or(true); - #[cfg(test)] - if let Some(hook) = self.terminal_move_before_projection.lock().unwrap().clone() { - hook(); - } - let (placement, topology_changed) = - if terminal.lifecycle == TerminalLifecycle::Tombstoned { - (None, false) - } else { - self.project_terminal_to_workspace_in_state( - &mut state, - terminal_id, - &terminal.workspace_key, - )? - }; - (terminal, current_revision, true, changed, placement, topology_changed) - } else { - let snapshot = registry.terminal_snapshot()?; - let mut terminal = registry - .terminal_record(terminal_id)? - .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; - if terminal.lifecycle == TerminalLifecycle::Tombstoned { - anyhow::bail!("terminal is already closed"); - } - if let Some(expected) = expected_incarnation - && terminal.incarnation.as_deref() != Some(expected) - { - anyhow::bail!("terminal_incarnation_mismatch"); - } - let changed = terminal.workspace_key != workspace_key; - terminal.workspace_key = workspace_key.to_string(); - let commit = registry.commit_terminal( - mutation, - &fingerprint, - expected_generation, - expected_revision.or(Some(snapshot.revision)), - "terminal-moved", - &terminal, - &serde_json::json!({ - "terminal_id":terminal_id, - "workspace_key":workspace_key, - "incarnation":terminal.incarnation, - "state":terminal.lifecycle, - "changed":changed, - }), - )?; - self.emit_terminal_registry_changed(®istry, commit.revision); - #[cfg(test)] - if let Some(hook) = self.terminal_move_before_projection.lock().unwrap().clone() { - hook(); - } - let (placement, topology_changed) = self.project_terminal_to_workspace_in_state( - &mut state, - terminal_id, - &terminal.workspace_key, - )?; - (terminal, commit.revision, false, changed, placement, topology_changed) +fn restore_resource_state( + snapshot: RegistrySnapshot, + topology: ResourceTopologySnapshot, +) -> anyhow::Result { + anyhow::ensure!( + topology.session_id == snapshot.session_id, + "resource topology belongs to a different session" + ); + anyhow::ensure!( + topology.generation == snapshot.generation, + "resource topology generation changed during startup" + ); + anyhow::ensure!( + topology.revision == snapshot.resource_revision, + "resource topology revision changed during startup" + ); + + let mut next_id = snapshot.next_numeric_id.max(1); + let mut allocate = || -> anyhow::Result { + let id = next_id; + next_id = + next_id.checked_add(1).ok_or_else(|| anyhow::anyhow!("runtime id space exhausted"))?; + Ok(id) + }; + + let workspace_revision = snapshot.revision; + let resource_revision = snapshot.resource_revision; + let mut workspaces = snapshot + .workspaces + .into_iter() + .map(|workspace| Workspace { + id: workspace.id, + public_id: workspace.public_id, + key: workspace.key, + name: workspace.name, + screens: Vec::new(), + active_screen: 0, + }) + .collect::>(); + let workspace_index_by_public = workspaces + .iter() + .enumerate() + .map(|(index, workspace)| (workspace.public_id.clone(), index)) + .collect::>(); + + let mut screen_slots = HashMap::new(); + for screen in &topology.screens { + let old = screen_slots.insert(screen.public_id.clone(), allocate()?); + anyhow::ensure!(old.is_none(), "duplicate screen {}", screen.public_id); + } + let mut pane_slots = HashMap::new(); + for pane in &topology.panes { + let old = pane_slots.insert(pane.public_id.clone(), allocate()?); + anyhow::ensure!(old.is_none(), "duplicate pane {}", pane.public_id); + } + let mut tab_slots = HashMap::new(); + for tab in &topology.tabs { + let old = tab_slots.insert(tab.public_id.clone(), allocate()?); + anyhow::ensure!(old.is_none(), "duplicate tab {}", tab.public_id); + } + + let mut indexes = PublicSlotIndexes::default(); + for workspace in &workspaces { + anyhow::ensure!( + indexes.workspaces.insert(workspace.public_id.clone(), workspace.id).is_none(), + "duplicate workspace {}", + workspace.public_id + ); + indexes.workspace_ids.insert(workspace.id, workspace.public_id.clone()); + } + for (public_id, slot) in &screen_slots { + indexes.screens.insert(public_id.clone(), *slot); + indexes.screen_ids.insert(*slot, public_id.clone()); + } + for (public_id, slot) in &pane_slots { + indexes.panes.insert(public_id.clone(), *slot); + indexes.pane_ids.insert(*slot, public_id.clone()); + } + + let mut browsers_by_id = topology + .browsers + .iter() + .cloned() + .map(|browser| (browser.public_id.clone(), browser)) + .collect::>(); + anyhow::ensure!( + browsers_by_id.len() == topology.browsers.len(), + "resource topology contains duplicate browser metadata" + ); + let mut tabs_by_pane = HashMap::>::new(); + let mut contents = Vec::with_capacity(topology.tabs.len()); + for tab in topology.tabs { + let browser = match (&tab.content_id, &tab.browser_url, &tab.terminal_id) { + (ContentPublicId::Terminal(_), None, Some(_)) => None, + (ContentPublicId::Browser(browser_id), Some(url), None) => { + let browser = browsers_by_id.remove(browser_id).ok_or_else(|| { + anyhow::anyhow!("browser tab {} has no restart metadata", tab.public_id) + })?; + anyhow::ensure!( + &browser.url == url, + "browser tab {} URL disagrees with its restart metadata", + tab.public_id + ); + Some(browser) + } + _ => { + anyhow::bail!("tab {} has inconsistent persisted content metadata", tab.public_id) } }; - if placement.is_some() - && let Some(surface) = placement.and_then(|placement| self.surface(placement.surface)) - { - let _ = surface.persist_host_workspace(&terminal.workspace_key); - } - if topology_changed { - self.emit(MuxEvent::TreeChanged); - } - Ok(TerminalMoveResult { placement, terminal, terminal_revision, replayed, changed }) + let slot = tab_slots[&tab.public_id]; + let identity = TabResourceIdentity::new(tab.public_id.clone(), tab.content_id.clone()); + anyhow::ensure!( + indexes.tabs.insert(tab.public_id.clone(), slot).is_none(), + "duplicate tab {}", + tab.public_id + ); + indexes.tab_ids.insert(slot, tab.public_id.clone()); + indexes.content_placements.entry(tab.content_id.clone()).or_default().push(slot); + indexes.content_ids.insert(slot, tab.content_id.clone()); + contents.push(RestoredResourceContent { slot, identity, name: tab.name.clone(), browser }); + tabs_by_pane.entry(tab.pane_id.clone()).or_default().push(tab); + } + anyhow::ensure!( + browsers_by_id.is_empty(), + "resource topology contains orphan browser metadata" + ); + for tabs in tabs_by_pane.values_mut() { + tabs.sort_by_key(|tab| tab.position); } - fn project_terminal_to_workspace_in_state( - &self, - state: &mut State, - terminal_id: &str, - workspace_key: &str, - ) -> anyhow::Result<(Option, bool)> { - let Some(runtime) = self.catalog_terminal_by_host(state, terminal_id)? else { - // Still validate the in-memory projection while both writer locks - // are held; a missing destination indicates registry/state drift. - if state.workspaces.iter().all(|workspace| workspace.key != workspace_key) { - anyhow::bail!("unknown workspace key {workspace_key}"); + let mut panes = HashMap::new(); + for pane in &topology.panes { + let id = pane_slots[&pane.public_id]; + let pane_tabs = tabs_by_pane.get(&pane.public_id).map(Vec::as_slice).unwrap_or_default(); + let tabs = pane_tabs.iter().map(|tab| tab_slots[&tab.public_id]).collect::>(); + let active_tab = match pane.active_tab.as_ref() { + Some(active) => { + pane_tabs.iter().position(|tab| &tab.public_id == active).ok_or_else(|| { + anyhow::anyhow!("pane {} has unknown active tab {}", pane.public_id, active) + })? } - return Ok((None, false)); - }; - let surface = terminal_placement_for_runtime(state, &runtime); - let Some(surface) = surface else { - // A terminal with no view remains alive. Creating another view is - // an explicit `terminal.project` operation, not an implicit move. - return Ok((None, false)); + None if pane_tabs.is_empty() => 0, + None => anyhow::bail!("pane {} has tabs but no active tab", pane.public_id), }; - let active_at = self.next_active_at(); - let preserved_focus = current_focus_identity(state); - let destination = state - .workspaces - .iter() - .position(|workspace| workspace.key == workspace_key) - .ok_or_else(|| anyhow::anyhow!("unknown workspace key {workspace_key}"))?; - if let Some(current) = run_placement_for_surface(state, surface) - && current.workspace == state.workspaces[destination].id - { - return Ok((Some(current), false)); + anyhow::ensure!( + panes + .insert( + id, + Pane { + id, + public_id: pane.public_id.clone(), + name: pane.name.clone(), + tabs, + active_tab, + active_at: pane.creation_ordinal, + focused_at: 0, + }, + ) + .is_none(), + "duplicate pane slot {id}" + ); + let screen = *screen_slots + .get(&pane.screen_id) + .ok_or_else(|| anyhow::anyhow!("pane {} has unknown screen", pane.public_id))?; + indexes.pane_screen.insert(id, screen); + for tab in pane_tabs { + indexes.tab_pane.insert(tab_slots[&tab.public_id], id); } - let target_pane = if let Some(pane) = - state.workspaces[destination].active_screen_ref().map(|screen| screen.active_pane) - { - pane - } else { - let pane = self.next_id(); - let screen = self.next_id(); - state.insert_pane(Pane { - id: pane, - public_id: PanePublicId::random()?, - name: None, - tabs: Vec::new(), - active_tab: 0, - active_at, - // The projection preserves the user's existing focus - // identity below; this destination starts unfocused. - focused_at: 0, - }); - state.workspaces[destination].screens.push(Screen { - id: screen, - public_id: ScreenPublicId::random()?, - name: None, - root: Node::Leaf(pane), - active_pane: pane, - zoomed_pane: None, - zellij_auto_layout: Some(vec![pane]), - viewport_splits: Default::default(), - viewport_base_width: None, - layout_columns: Vec::new(), - layout_revision: 0, - layout_undo: Default::default(), - }); - state.workspaces[destination].active_screen = 0; - pane + } + + let mut split_slots = HashMap::::new(); + let mut screens_by_workspace = HashMap::>::new(); + for screen in &topology.screens { + let expected_panes = topology + .panes + .iter() + .filter(|pane| pane.screen_id == screen.public_id) + .map(|pane| pane.public_id.clone()) + .collect::>(); + crate::workspace_registry::validate_registry_screen_projection(screen, &expected_panes)?; + let id = screen_slots[&screen.public_id]; + let root = + restore_layout_node(&screen.layout, &pane_slots, &mut split_slots, &mut allocate)?; + let active_pane = *pane_slots.get(&screen.active_pane).ok_or_else(|| { + anyhow::anyhow!("screen {} has unknown active pane", screen.public_id) + })?; + let zoomed_pane = screen + .zoomed_pane + .as_ref() + .map(|pane| { + pane_slots.get(pane).copied().ok_or_else(|| { + anyhow::anyhow!("screen {} has unknown zoomed pane {}", screen.public_id, pane) + }) + }) + .transpose()?; + let zellij_auto_layout = screen + .auto_layout + .as_ref() + .map(|panes| { + panes + .iter() + .map(|pane| { + pane_slots.get(pane).copied().ok_or_else(|| { + anyhow::anyhow!( + "screen {} auto-layout has unknown pane {}", + screen.public_id, + pane + ) + }) + }) + .collect::>>() + }) + .transpose()?; + let (viewport_splits, viewport_base_width, layout_columns) = restore_registry_viewport( + &screen.viewport, + &pane_slots, + &mut split_slots, + &mut allocate, + )?; + indexes.screen_workspace.insert( + id, + workspaces[*workspace_index_by_public.get(&screen.workspace_id).ok_or_else(|| { + anyhow::anyhow!("screen {} has unknown workspace", screen.public_id) + })?] + .id, + ); + let restored_screen = Screen { + id, + public_id: screen.public_id.clone(), + name: screen.name.clone(), + root, + active_pane, + zoomed_pane, + zellij_auto_layout, + viewport_splits, + viewport_base_width, + layout_columns, + layout_revision: 0, + layout_undo: Default::default(), }; - if state.pane_of(surface).is_some() { - let (moved, topology_changed) = - move_tab_in_state(self, state, surface, target_pane, usize::MAX); - if !moved { - anyhow::bail!("terminal topology changed during move"); + anyhow::ensure!( + restored_screen.layout_column_projection_is_consistent(), + "screen {} has inconsistent viewport projection", + screen.public_id + ); + screens_by_workspace + .entry(screen.workspace_id.clone()) + .or_default() + .push((screen.position, restored_screen)); + } + for (workspace_id, mut screens) in screens_by_workspace { + screens.sort_by_key(|(position, _)| *position); + let workspace_index = workspace_index_by_public[&workspace_id]; + workspaces[workspace_index].screens = + screens.into_iter().map(|(_, screen)| screen).collect(); + } + let mut active_screens = HashMap::new(); + for (workspace, active) in topology.active_screens { + anyhow::ensure!( + active_screens.insert(workspace.clone(), active).is_none(), + "workspace {workspace} has duplicate active-screen metadata" + ); + } + anyhow::ensure!( + active_screens.len() == workspaces.len() + && workspaces.iter().all(|workspace| active_screens.contains_key(&workspace.public_id)), + "active-screen metadata does not exactly cover the live workspaces" + ); + for workspace in &mut workspaces { + workspace.active_screen = match active_screens[&workspace.public_id].as_ref() { + Some(active) => { + workspace.screens.iter().position(|screen| &screen.public_id == active).ok_or_else( + || { + anyhow::anyhow!( + "workspace {} has unknown active screen {}", + workspace.public_id, + active + ) + }, + )? } - if topology_changed { - Self::rebuild_split_screen_index(state); + None if workspace.screens.is_empty() => 0, + None => { + anyhow::bail!("workspace {} has screens but no active screen", workspace.public_id) } - } else { - let pane = state - .panes - .get_mut(&target_pane) - .ok_or_else(|| anyhow::anyhow!("destination pane disappeared"))?; - pane.tabs.push(surface); - pane.active_tab = pane.tabs.len() - 1; - pane.active_at = active_at; - state.resource_indexes.tab_pane.insert(surface, target_pane); - fence_layout_undo_for_tab_membership(state, &[target_pane]); - } - restore_focus_identity(state, preserved_focus); - let placement = run_placement_for_surface(state, surface) - .ok_or_else(|| anyhow::anyhow!("terminal move did not produce a binding"))?; - Ok((Some(placement), true)) + }; + } + let active_workspace = match topology.active_workspace.as_ref() { + Some(active) => workspaces + .iter() + .position(|workspace| &workspace.public_id == active) + .ok_or_else(|| anyhow::anyhow!("unknown active workspace {active}"))?, + None if workspaces.is_empty() => 0, + None => anyhow::bail!("session has workspaces but no active workspace"), + }; + + for (public_id, slot) in split_slots { + indexes.splits.insert(public_id.clone(), slot); + indexes.split_ids.insert(slot, public_id); } + let workspace_index_by_id = + workspaces.iter().enumerate().map(|(index, workspace)| (workspace.id, index)).collect(); + let workspace_id_by_key = + workspaces.iter().map(|workspace| (workspace.key.clone(), workspace.id)).collect(); + Ok(RestoredResourceState { + state: State { + workspaces, + workspace_index_by_id, + workspace_id_by_key, + workspace_revision, + pane_revision: panes.len() as u64, + resource_revision, + focus_sequence: 0, + active_workspace, + panes, + surfaces: HashMap::new(), + terminal_catalog: HashMap::new(), + terminal_catalog_by_runtime: HashMap::new(), + split_screens: HashMap::new(), + resource_indexes: indexes, + }, + next_id, + contents, + }) +} - /// Move an existing tab to `index` in `pane`. The surface is kept - /// alive; if moving it empties the source pane, that pane collapses - /// out of its split tree. - pub fn move_tab(self: &Arc, surface: SurfaceId, pane: PaneId, index: usize) -> bool { - if self.with_state(|state| { - let Some(source) = state.pane_of(surface) else { return false }; - if source != pane { - return false; - } - let Some(pane) = state.panes.get(&pane) else { return false }; - let Some(old_index) = pane.tabs.iter().position(|candidate| *candidate == surface) - else { - return false; +fn restore_layout_node( + node: &RegistryLayoutNode, + panes: &HashMap, + splits: &mut HashMap, + allocate: &mut impl FnMut() -> anyhow::Result, +) -> anyhow::Result { + Ok(match node { + RegistryLayoutNode::Leaf { pane } => Node::Leaf( + *panes.get(pane).ok_or_else(|| anyhow::anyhow!("layout has unknown pane {pane}"))?, + ), + RegistryLayoutNode::Split { split, direction, ratio, first, second } => { + anyhow::ensure!(!splits.contains_key(split), "split {split} appears more than once"); + let id = allocate()?; + splits.insert(split.clone(), id); + let dir = match direction.as_str() { + "right" => SplitDir::Right, + "down" => SplitDir::Down, + _ => anyhow::bail!("split {split} has invalid direction {direction:?}"), }; - let final_index = if index > old_index { index.saturating_sub(1) } else { index } - .min(pane.tabs.len().saturating_sub(1)); - final_index == old_index - }) { - return false; + Node::Split { + id, + dir, + ratio: *ratio, + a: Box::new(restore_layout_node(first, panes, splits, allocate)?), + b: Box::new(restore_layout_node(second, panes, splits, allocate)?), + } } - let Some(selectors) = self.ordinary_tab_selectors(surface) else { return false }; - let Some((destination_workspace, destination_screen, destination_pane, changed_screen)) = - self.with_state(|state| { - let (workspace, screen) = state.screen_of(pane)?; - let source_pane = state.pane_of(surface)?; - let source_screen = (source_pane != pane - && state.panes.get(&source_pane)?.tabs.len() == 1) - .then(|| { - state.screen_of(source_pane).map(|(workspace, screen)| { - state.workspaces[workspace].screens[screen].id - }) - }) - .flatten(); - Some(( - state.workspaces[workspace].public_id.to_string(), - state.workspaces[workspace].screens[screen].public_id.to_string(), - state.resource_indexes.pane_ids.get(&pane)?.to_string(), - source_screen, - )) - }) - else { - return false; - }; - let fields = Map::from_iter([ - ("destination_workspace".into(), Value::String(destination_workspace)), - ("destination_screen".into(), Value::String(destination_screen)), - ("destination_pane".into(), Value::String(destination_pane)), - ("index".into(), Value::from(u64::try_from(index).unwrap_or(u64::MAX))), - ]); - let Ok(commit) = - self.commit_ordinary_topology_operation(ResourceOperation::TabMove, selectors, fields) - else { - return false; - }; - if let Some(surface) = self.surface(surface) - && let Some(workspace_key) = self.workspace_key_for_pane(pane) - { - let _ = surface.persist_host_workspace(&workspace_key); + RegistryLayoutNode::Stack { panes: members, expanded } => { + let members = members + .iter() + .map(|pane| { + panes + .get(pane) + .copied() + .ok_or_else(|| anyhow::anyhow!("stack has unknown pane {pane}")) + }) + .collect::>>()?; + let expanded = *panes + .get(expanded) + .ok_or_else(|| anyhow::anyhow!("stack has unknown expanded pane {expanded}"))?; + Node::stack_with_expanded(members, expanded) + .ok_or_else(|| anyhow::anyhow!("stored stack is empty or has invalid selection"))? } - self.emit_resource_topology_legacy_events(ResourceOperation::TabMove, &commit); - if let Some(screen) = changed_screen.filter(|source| { - self.with_state(|state| { - state - .workspaces + }) +} + +fn restore_registry_viewport( + viewport: &RegistryViewport, + panes: &HashMap, + splits: &mut HashMap, + allocate: &mut impl FnMut() -> anyhow::Result, +) -> anyhow::Result { + if viewport.columns.is_empty() { + return Ok((Default::default(), None, Vec::new())); + } + let mut columns = Vec::with_capacity(viewport.columns.len()); + for (index, column) in viewport.columns.iter().enumerate() { + let id = match splits.get(&column.id).copied() { + Some(id) => id, + None if index == 0 => { + let id = allocate()?; + splits.insert(column.id.clone(), id); + id + } + None => anyhow::bail!("viewport references unknown boundary split {}", column.id), + }; + let root = restore_layout_node_from_known_splits(&column.layout, panes, splits)?; + let zellij_auto_layout = column + .auto_layout + .as_ref() + .map(|members| { + members .iter() - .any(|workspace| workspace.screens.iter().any(|screen| screen.id == *source)) + .map(|pane| { + panes.get(pane).copied().ok_or_else(|| { + anyhow::anyhow!("viewport auto-layout has unknown pane {pane}") + }) + }) + .collect::>>() }) - }) { - self.emit(MuxEvent::LayoutChanged(screen)); - } - true - } - - /// Reorder a workspace. The active workspace follows the moved entry. - pub fn move_workspace(&self, workspace: WorkspaceId, index: usize) -> bool { - self.move_workspace_at_revision(workspace, index, None) - .map(|result| result.is_some_and(|(_, changed)| changed)) - .unwrap_or(false) + .transpose()?; + columns.push(LayoutColumn { id, width: column.width, root, zellij_auto_layout }); } - - pub fn move_workspace_at_revision( - &self, - workspace: WorkspaceId, - index: usize, - expected_revision: Option, - ) -> anyhow::Result> { - { - let state = self.state.lock().unwrap(); - let Some(old_index) = state.workspace_index(workspace) else { - return Ok(None); + let viewport_splits = columns.iter().skip(1).map(|column| (column.id, column.width)).collect(); + Ok((viewport_splits, viewport.base_width, columns)) +} + +fn restore_layout_node_from_known_splits( + node: &RegistryLayoutNode, + panes: &HashMap, + splits: &HashMap, +) -> anyhow::Result { + Ok(match node { + RegistryLayoutNode::Leaf { pane } => Node::Leaf( + *panes.get(pane).ok_or_else(|| anyhow::anyhow!("layout has unknown pane {pane}"))?, + ), + RegistryLayoutNode::Split { split, direction, ratio, first, second } => { + let id = *splits + .get(split) + .ok_or_else(|| anyhow::anyhow!("layout has unknown split {split}"))?; + let dir = match direction.as_str() { + "right" => SplitDir::Right, + "down" => SplitDir::Down, + _ => anyhow::bail!("split {split} has invalid direction {direction:?}"), }; - if let Some(expected) = expected_revision - && expected != state.workspace_revision - { - anyhow::bail!( - "workspace revision conflict: expected {expected}, current {}", - state.workspace_revision - ); - } - let new_index = if index > old_index { index.saturating_sub(1) } else { index }; - let new_index = new_index.min(state.workspaces.len().saturating_sub(1)); - if new_index == old_index { - return Ok(Some((state.workspace_revision, false))); + Node::Split { + id, + dir, + ratio: *ratio, + a: Box::new(restore_layout_node_from_known_splits(first, panes, splits)?), + b: Box::new(restore_layout_node_from_known_splits(second, panes, splits)?), } } - let mutation = WorkspaceMutation::local("cmux-tui"); - let result = self.move_workspace_with_mutation( - Some(workspace), - None, - index, - None, - expected_revision, - &mutation, - )?; - Ok(Some((result.revision, result.changed))) + RegistryLayoutNode::Stack { panes: members, expanded } => { + let members = members + .iter() + .map(|pane| { + panes + .get(pane) + .copied() + .ok_or_else(|| anyhow::anyhow!("stack has unknown pane {pane}")) + }) + .collect::>>()?; + let expanded = *panes + .get(expanded) + .ok_or_else(|| anyhow::anyhow!("stack has unknown expanded pane {expanded}"))?; + Node::stack_with_expanded(members, expanded) + .ok_or_else(|| anyhow::anyhow!("stored stack is empty or has invalid selection"))? + } + }) +} + +/// Every surface in a screen (all panes, all tabs). +fn screen_tabs(state: &State, screen: &Screen) -> Vec { + let mut pane_ids = Vec::new(); + screen.root.pane_ids(&mut pane_ids); + pane_ids + .iter() + .filter_map(|id| state.panes.get(id)) + .flat_map(|pane| pane.tabs.iter().copied()) + .collect() +} + +fn stamp_pane_focus(mux: &Mux, state: &mut State, pane: PaneId) { + let focused_at = state.next_focus_sequence(); + let active_at = mux.next_active_at(); + if let Some(pane) = state.panes.get_mut(&pane) { + pane.active_at = active_at; + pane.focused_at = focused_at; } +} - #[allow(clippy::too_many_arguments)] - pub fn move_workspace_with_mutation( - &self, - workspace: Option, - requested_key: Option<&str>, - index: usize, - expected_generation: Option<&str>, - expected_revision: Option, - mutation: &WorkspaceMutation, - ) -> anyhow::Result { - let fingerprint = serde_json::json!({ - "op": "move-workspace", - "workspace": workspace, - "key": requested_key, - "index": index, - }); - let notifications = self.surface_notifications(); - let mut registry = self.workspace_registry.lock().unwrap(); - if let Some(commit) = registry.replay(mutation, &fingerprint)? { - return workspace_mutation_result(&commit); - } - let (delta, result) = { - let mut state = self.state.lock().unwrap(); - Self::require_workspace_revision(&state, expected_revision)?; - let old_idx = resolve_workspace_index(&state, workspace, requested_key)?; - let workspace_id = state.workspaces[old_idx].id; - let key = state.workspaces[old_idx].key.clone(); - // Protocol v7 uses insertion-point semantics. Once the source is - // removed, insertion points to its right shift left by one. - let new_idx = if index > old_idx { index.saturating_sub(1) } else { index }; - let new_idx = new_idx.min(state.workspaces.len().saturating_sub(1)); - let changed = new_idx != old_idx; - let mut desired = self.registry_projection(&state); - let desired_workspace = desired.remove(old_idx); - desired.insert(new_idx, desired_workspace); - let desired_active_workspace = - state.workspaces.get(state.active_workspace).map(|workspace| &workspace.public_id); - let commit = registry.commit_with_active_workspace( - mutation, - &fingerprint, - expected_generation, - expected_revision, - "workspace-moved", - &key, - &desired, - desired_active_workspace, - &serde_json::json!({ - "workspace": workspace_id, - "key": key.clone(), - "index": new_idx, - "changed": changed, - }), - )?; - let resource_revision = registry.snapshot()?.resource_revision; - let active_id = state.workspaces.get(state.active_workspace).map(|ws| ws.id); - state.move_workspace(old_idx, new_idx); - state.active_workspace = active_id - .and_then(|id| state.workspace_index(id)) - .unwrap_or_else(|| state.workspaces.len().saturating_sub(1)); - Self::rebuild_split_screen_index(&mut state); - state.workspace_revision = commit.revision; - state.resource_revision = resource_revision; - let workspace_revision = commit.revision; - let entity = crate::server::tree_entity_json( - &state, - ¬ifications, - TreeDeltaKind::WorkspaceMoved, - workspace_id, - ) - .expect("moved workspace is present in tree snapshot"); - ( - TreeDelta { - kind: TreeDeltaKind::WorkspaceMoved, - workspace: workspace_id, - screen: None, - pane: None, - surface: None, - index: Some(new_idx), - entity, - workspace_revision: Some(workspace_revision), - }, - workspace_mutation_result(&commit)?, - ) - }; - self.emit_committed_workspace_delta(®istry, delta, false); - drop(registry); - self.publish_resource_event(); - Ok(result) +fn stamp_changed_active_pane(mux: &Mux, state: &mut State, previous: Option) { + let current = state.active_pane(); + if current != previous + && let Some(pane) = current + { + stamp_pane_focus(mux, state, pane); } +} - /// Select a tab within a pane (default: the active pane) by index or - /// relative delta. - pub fn select_tab( - self: &Arc, - pane: Option, - index: Option, - delta: Option, - ) { - let surface = { - let state = self.state.lock().unwrap(); - let Some(target) = pane.or_else(|| state.active_pane()) else { return }; - let Some(pane) = state.panes.get(&target) else { return }; - let len = pane.tabs.len(); - if len == 0 { - return; +fn most_recent_pane(state: &State, panes: &[PaneId]) -> Option { + panes + .iter() + .filter_map(|id| state.panes.get(id).map(|pane| (*id, pane.active_at))) + .max_by_key(|(_, active_at)| *active_at) + .map(|(id, _)| id) +} + +fn clamp_split_ratio(ratio: f32) -> f32 { + ratio.clamp(0.05, 0.95) +} + +fn append_to_auto_layout( + root: &mut Node, + auto_layout: &mut Option>, + pane: PaneId, + mut next_id: impl FnMut() -> SplitId, +) { + let mut panes = auto_layout.clone().unwrap_or_else(|| { + let mut panes = Vec::new(); + root.pane_ids(&mut panes); + panes.sort_unstable(); + panes + }); + let current_panes = root.pane_ids_vec().into_iter().collect::>(); + panes.retain(|pane| current_panes.contains(pane)); + panes.push(pane); + *root = crate::layout::zellij_default_pane_layout_with_ids(&panes, &mut next_id) + .expect("new pane layout always has at least one pane"); + *auto_layout = Some(panes); +} + +fn remove_pane_from_screen_layout(mux: &Mux, screen: &mut Screen, pane: PaneId) -> bool { + screen.invalidate_layout_undo(); + if screen.layout_columns_active() { + let Some(index) = + screen.layout_columns.iter().position(|column| column.root.contains(pane)) + else { + return true; + }; + let column = &mut screen.layout_columns[index]; + let root = std::mem::replace(&mut column.root, Node::Leaf(0)); + let stack_expanded = root.stack_expanded_pane(); + match root.remove_leaf(pane) { + Some(mut root) => { + if let Some(panes) = column.zellij_auto_layout.as_mut() { + panes.retain(|candidate| *candidate != pane); + if let Some(layout) = + crate::layout::zellij_default_pane_layout_with_ids(panes, &mut || { + mux.next_id() + }) + { + root = layout; + if let Some(expanded) = stack_expanded { + root.expand_stack_pane(expanded); + } + } else { + column.zellij_auto_layout = None; + } + } + column.root = root; + } + None => { + screen.layout_columns.remove(index); } - let selected = if let Some(index) = index.filter(|index| *index < len) { - index - } else if let Some(delta) = delta { - ((pane.active_tab as isize + delta).rem_euclid(len as isize)) as usize - } else { - pane.active_tab - }; - pane.tabs[selected] - }; - let Some(selectors) = self.ordinary_tab_selectors(surface) else { return }; - if self.commit_ordinary_tab_selection(selectors).is_err() { - return; } - let viewed = self.with_state(Self::active_surface_in_state); - self.clear_viewed_notification(viewed); - self.emit(MuxEvent::TreeChanged); + if screen.layout_columns.is_empty() { + return false; + } + screen.collapse_single_layout_column(); + return true; } - /// Select a screen in the active workspace by index or relative delta. - pub fn select_screen(self: &Arc, index: Option, delta: Option) { - let screen = { - let state = self.state.lock().unwrap(); - let active = state.active_workspace; - let Some(ws) = state.workspaces.get(active) else { return }; - let len = ws.screens.len(); - if len == 0 { - return; - } - let selected = if let Some(index) = index.filter(|index| *index < len) { - index - } else if let Some(delta) = delta { - ((ws.active_screen as isize + delta).rem_euclid(len as isize)) as usize - } else { - ws.active_screen - }; - ws.screens[selected].id - }; - let Some(selectors) = self.ordinary_screen_selectors(screen) else { return }; - if self - .commit_ordinary_topology_operation( - ResourceOperation::ScreenFocus, - selectors, - Map::new(), - ) - .is_err() + let root = std::mem::replace(&mut screen.root, Node::Leaf(0)); + let stack_expanded = root.stack_expanded_pane(); + let Some(mut root) = root.remove_leaf(pane) else { + return false; + }; + if let Some(panes) = screen.zellij_auto_layout.as_mut() { + panes.retain(|candidate| *candidate != pane); + if let Some(layout) = + crate::layout::zellij_default_pane_layout_with_ids(panes, &mut || mux.next_id()) { - return; + root = layout; + if let Some(expanded) = stack_expanded { + root.expand_stack_pane(expanded); + } + } else { + screen.zellij_auto_layout = None; } - let viewed = self.with_state(Self::active_surface_in_state); - self.clear_viewed_notification(viewed); - self.emit(MuxEvent::TreeChanged); } + screen.root = root; + true +} - /// Select a workspace by index or relative delta. - pub fn select_workspace(self: &Arc, index: Option, delta: Option) { - let workspace = { - let state = self.state.lock().unwrap(); - let len = state.workspaces.len(); - if len == 0 { - return; +fn unique_screen_ids(ids: impl IntoIterator) -> Vec { + let mut unique = Vec::new(); + for id in ids { + if !unique.contains(&id) { + unique.push(id); + } + } + unique +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct ActiveTreeSelection { + workspace: Option, + screen: Option, + pane: Option, + surface: Option, +} + +fn active_tree_selection(state: &State) -> ActiveTreeSelection { + let workspace = state.workspaces.get(state.active_workspace); + let screen = workspace.and_then(|workspace| workspace.screens.get(workspace.active_screen)); + let pane = screen.and_then(|screen| state.panes.get(&screen.active_pane)); + ActiveTreeSelection { + workspace: workspace.map(|workspace| workspace.id), + screen: screen.map(|screen| screen.id), + pane: screen.map(|screen| screen.active_pane), + surface: pane.and_then(|pane| pane.tabs.get(pane.active_tab)).copied(), + } +} + +fn surface_screen_id(state: &State, surface: SurfaceId) -> Option { + let pane = state.pane_of(surface)?; + let (wi, si) = state.screen_of(pane)?; + Some(state.workspaces[wi].screens[si].id) +} + +fn resolve_workspace_index( + state: &State, + id: Option, + key: Option<&str>, +) -> anyhow::Result { + if id.is_none() && key.is_none() { + anyhow::bail!("workspace or key is required"); + } + let by_id = id.and_then(|id| state.workspaces.iter().position(|workspace| workspace.id == id)); + let by_key = + key.and_then(|key| state.workspaces.iter().position(|workspace| workspace.key == key)); + match (id, key, by_id, by_key) { + (Some(id), _, None, _) => anyhow::bail!("unknown workspace {id}"), + (_, Some(key), _, None) => anyhow::bail!("unknown workspace key {key}"), + (Some(_), Some(_), Some(left), Some(right)) if left != right => { + anyhow::bail!("workspace and key identify different workspaces") + } + (_, _, Some(index), _) | (_, _, _, Some(index)) => Ok(index), + _ => anyhow::bail!("unknown workspace"), + } +} + +fn workspace_mutation_result(commit: &RegistryCommit) -> anyhow::Result { + let workspace = commit.result["workspace"].as_u64(); + let key = commit.result["key"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("stored workspace mutation result is missing key"))? + .to_string(); + let index = commit.result["index"] + .as_u64() + .map(usize::try_from) + .transpose() + .context("stored workspace mutation index is invalid")?; + let changed = commit.result["changed"].as_bool().unwrap_or(true); + Ok(WorkspaceMutationResult { + workspace, + key, + index, + revision: commit.revision, + replayed: commit.replayed, + changed, + }) +} + +fn workspace_close_fingerprint(target: Option, key: Option<&str>) -> Value { + serde_json::json!({ + "op": "close-workspace", + "workspace": target, + "key": key, + }) +} + +fn local_workspace_close_mutation( + target: Option, + key: Option<&str>, + project_resource: bool, +) -> WorkspaceMutation { + let mut digest = Sha256::new(); + digest.update(b"cmux-tui/local-workspace-close/1\0"); + digest.update([u8::from(project_resource)]); + match target { + Some(target) => { + digest.update([1]); + digest.update(target.to_be_bytes()); + } + None => digest.update([0]), + } + match key { + Some(key) => { + digest.update([1]); + digest.update( + u64::try_from(key.len()).expect("workspace key length fits u64").to_be_bytes(), + ); + digest.update(key.as_bytes()); + } + None => digest.update([0]), + } + let payload = + digest.finalize()[..16].iter().map(|byte| format!("{byte:02x}")).collect::(); + WorkspaceMutation::new(format!("local-workspace-close-{payload}"), "cmux-tui") + .expect("derived local workspace close mutation is valid") +} + +fn terminal_host_cleanup_receipt(terminals: &[(String, Option)]) -> Value { + Value::Array( + terminals + .iter() + .map(|(terminal_id, incarnation)| { + serde_json::json!({ + "terminal_id": terminal_id, + "incarnation": incarnation, + }) + }) + .collect(), + ) +} + +fn terminal_host_cleanup_identities( + result: &Value, +) -> anyhow::Result)>> { + let Some(receipt) = result.get(TERMINAL_HOST_CLEANUP_RECEIPT_FIELD) else { + return Ok(Vec::new()); + }; + let entries = receipt + .as_array() + .ok_or_else(|| anyhow::anyhow!("stored terminal-host cleanup receipt is invalid"))?; + anyhow::ensure!( + entries.len() <= MAX_TERMINAL_HOST_CLEANUP_IDENTITIES, + "stored terminal-host cleanup receipt exceeds capacity" + ); + let mut seen = HashSet::with_capacity(entries.len()); + let mut identities = Vec::with_capacity(entries.len()); + for entry in entries { + let terminal_id = entry["terminal_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("stored terminal-host cleanup id is missing"))?; + validate_terminal_hex(terminal_id, "stored terminal-host cleanup id is invalid")?; + anyhow::ensure!( + seen.insert(terminal_id.to_string()), + "stored terminal-host cleanup receipt contains a duplicate id" + ); + let incarnation = match entry.get("incarnation") { + None | Some(Value::Null) => None, + Some(value) => { + let incarnation = value.as_str().ok_or_else(|| { + anyhow::anyhow!("stored terminal-host cleanup incarnation is invalid") + })?; + validate_terminal_hex( + incarnation, + "stored terminal-host cleanup incarnation is invalid", + )?; + Some(incarnation.to_string()) } - let selected = if let Some(index) = index.filter(|index| *index < len) { - index - } else if let Some(delta) = delta { - ((state.active_workspace as isize + delta).rem_euclid(len as isize)) as usize - } else { - state.active_workspace - }; - state.workspaces[selected].id }; - let Some(selectors) = self.ordinary_workspace_selectors(workspace) else { return }; - if self - .commit_ordinary_topology_operation( - ResourceOperation::WorkspaceFocus, - selectors, - Map::new(), - ) - .is_err() - { - return; - } - let viewed = self.with_state(Self::active_surface_in_state); - self.clear_viewed_notification(viewed); - self.emit(MuxEvent::TreeChanged); + identities.push((terminal_id.to_string(), incarnation)); + } + Ok(identities) +} + +pub(crate) fn remove_terminal_host_cleanup_receipt(result: &mut Value) { + if let Some(result) = result.as_object_mut() { + result.remove(TERMINAL_HOST_CLEANUP_RECEIPT_FIELD); + } +} + +fn close_surface_delta( + state: &State, + notifications: &HashMap, + surface: SurfaceId, +) -> Option { + let pane_id = state.pane_of(surface)?; + let pane = state.panes.get(&pane_id)?; + let tab_index = pane.tabs.iter().position(|candidate| *candidate == surface)?; + let (wi, si) = state.screen_of(pane_id)?; + let workspace = &state.workspaces[wi]; + let screen = &workspace.screens[si]; + if pane.tabs.len() > 1 { + let entity = crate::server::tree_entity_json( + state, + notifications, + TreeDeltaKind::TabClosed, + surface, + )?; + return Some(TreeDelta { + kind: TreeDeltaKind::TabClosed, + workspace: workspace.id, + screen: Some(screen.id), + pane: Some(pane_id), + surface: Some(surface), + index: Some(tab_index), + entity, + workspace_revision: None, + }); + } + close_pane_delta(state, notifications, pane_id) +} + +fn close_pane_delta( + state: &State, + notifications: &HashMap, + pane: PaneId, +) -> Option { + let (wi, si) = state.screen_of(pane)?; + let workspace = &state.workspaces[wi]; + let screen = &workspace.screens[si]; + let mut panes = Vec::new(); + screen.root.pane_ids(&mut panes); + if panes.len() > 1 { + let entity = + crate::server::tree_entity_json(state, notifications, TreeDeltaKind::PaneClosed, pane)?; + return Some(TreeDelta { + kind: TreeDeltaKind::PaneClosed, + workspace: workspace.id, + screen: Some(screen.id), + pane: Some(pane), + surface: None, + index: Some(panes.iter().position(|candidate| *candidate == pane)?), + entity, + workspace_revision: None, + }); } + close_screen_delta(state, notifications, screen.id) } -fn persist_public_topology_result( - operation: &str, - result: &mut Value, - changes: &Value, -) -> anyhow::Result<()> { - let Some((resource, identity_field)) = public_topology_result_target(operation) else { - return Ok(()); - }; - let id = result - .get(identity_field) - .and_then(Value::as_str) - .with_context(|| format!("{operation} result omitted its {identity_field} identity"))?; - let value = changes - .as_array() - .context("public topology changes are not an array")? - .iter() - .rev() - .find(|change| { - change["kind"] == "upsert" - && change["resource"] == resource - && change["id"].as_str() == Some(id) - }) - .and_then(|change| change.get("value")) - .cloned() - .with_context(|| { - format!("{operation} changes omitted the committed {resource} value for {id}") - })?; - result - .as_object_mut() - .context("public topology result is not an object")? - .insert("public_value".to_string(), value); - Ok(()) +fn close_screen_delta( + state: &State, + notifications: &HashMap, + screen: ScreenId, +) -> Option { + let (wi, si) = state.workspaces.iter().enumerate().find_map(|(wi, workspace)| { + workspace.screens.iter().position(|candidate| candidate.id == screen).map(|si| (wi, si)) + })?; + let workspace = &state.workspaces[wi]; + let entity = + crate::server::tree_entity_json(state, notifications, TreeDeltaKind::ScreenClosed, screen)?; + Some(TreeDelta { + kind: TreeDeltaKind::ScreenClosed, + workspace: workspace.id, + screen: Some(screen), + pane: None, + surface: None, + index: Some(si), + entity, + workspace_revision: None, + }) } -fn public_topology_result_target(operation: &str) -> Option<(&'static str, &'static str)> { - match operation { - "workspace.rename" | "workspace.move" | "workspace.focus" | "workspace.layout.apply" => { - Some(("workspace", "workspace")) - } - "screen.rename" | "screen.focus" | "screen.layout.undo" => Some(("screen", "screen")), - "pane.rename" - | "pane.focus" - | "pane.focus_direction" - | "pane.swap" - | "pane.zoom" - | "pane.split_ratio.set" - | "pane.viewport_width.set" => Some(("pane", "pane")), - "tab.rename" | "tab.move" | "tab.focus" => Some(("tab", "tab")), - _ => None, - } +fn close_workspace_delta( + state: &State, + notifications: &HashMap, + workspace: WorkspaceId, +) -> Option { + let index = state.workspace_index(workspace)?; + let entity = crate::server::tree_entity_json( + state, + notifications, + TreeDeltaKind::WorkspaceClosed, + workspace, + )?; + Some(TreeDelta { + kind: TreeDeltaKind::WorkspaceClosed, + workspace, + screen: None, + pane: None, + surface: None, + index: Some(index), + entity, + workspace_revision: None, + }) } -fn terminal_launch_spec(options: &SurfaceOptions) -> Value { - let cmux_env = options - .extra_env - .iter() - .map(|(key, _)| key.as_str()) - .filter(|key| { - matches!( - *key, - "CMUX_TUI_SOCKET" - | "CMUX_MUX_SOCKET" - | "CMUX_TUI_HOOK" - | "CMUX_TUI_SESSION_ID" - | "CMUX_TUI_TERMINAL_ID" - | "CMUX_SIDEBAR" - ) - }) - .collect::>(); - serde_json::json!({ - // This is diagnostic shape, not a respawn recipe. argv and cwd can - // both contain credentials and missing hosts are never recreated. - "command_present": options.command.is_some(), - "cwd_present": options.cwd.is_some(), - "term": options.term, - "cols": options.cols, - "rows": options.rows, - "scrollback": options.scrollback, - // Values are deliberately absent: launch environments routinely - // contain bearer credentials and SQLite is durable frontend state, - // not a secret store or a shell-respawn recipe. - "cmux_env": cmux_env, - }) +fn update_layout_undo_token_part(hasher: &mut Sha256, value: &[u8]) { + hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes()); + hasher.update(value); } -/// Durable exactly-once metadata must distinguish retries without turning the -/// workspace registry into a second secret store. Command arguments, cwd, and -/// user-provided names can all contain credentials, so only their digest is -/// persisted alongside the non-secret routing identity. -fn terminal_create_fingerprint( - workspace_key: &str, - terminal_id: Option<&str>, - argv: Option<&[String]>, - cwd: Option<&str>, - name: Option<&str>, - size: Option<(u16, u16)>, +fn layout_undo_confirmation_details( + state: &State, + registry: &WorkspaceRegistry, + workspace_index: usize, + screen_index: usize, ) -> anyhow::Result { - let request = serde_json::json!({ - "argv": argv, - "cwd": cwd, - "name": name, - "size": size, - }); - let digest = Sha256::digest(serde_json::to_vec(&request)?); - let request_sha256 = digest.iter().map(|byte| format!("{byte:02x}")).collect::(); + let screen = state + .workspaces + .get(workspace_index) + .and_then(|workspace| workspace.screens.get(screen_index)) + .context("layout undo screen disappeared")?; + let entry = screen.layout_undo.back().ok_or(LayoutUndoError::Unavailable)?; + if entry.after_revision != screen.layout_revision { + return Err(LayoutUndoError::Stale( + "layout changed since the last undoable action".to_string(), + ) + .into()); + } + anyhow::ensure!(!entry.created_panes.is_empty(), "layout undo does not require confirmation"); + + let mut hasher = Sha256::new(); + update_layout_undo_token_part(&mut hasher, b"cmux.layout-undo.confirmation.v1"); + update_layout_undo_token_part(&mut hasher, registry.generation().as_bytes()); + update_layout_undo_token_part(&mut hasher, screen.public_id.to_string().as_bytes()); + update_layout_undo_token_part(&mut hasher, &screen.layout_revision.to_be_bytes()); + hasher.update(u64::try_from(entry.created_panes.len()).unwrap_or(u64::MAX).to_be_bytes()); + + let mut closes_panes = Vec::with_capacity(entry.created_panes.len()); + for created in &entry.created_panes { + let pane_id = state + .resource_indexes + .pane_ids + .get(created) + .with_context(|| format!("pane {created} has no public identity"))?; + let pane = state + .panes + .get(created) + .with_context(|| format!("created pane {created} disappeared before undo preview"))?; + closes_panes.push(pane_id.clone()); + update_layout_undo_token_part(&mut hasher, pane_id.to_string().as_bytes()); + hasher.update(u64::try_from(pane.tabs.len()).unwrap_or(u64::MAX).to_be_bytes()); + for surface in &pane.tabs { + let tab_id = state + .resource_indexes + .tab_ids + .get(surface) + .cloned() + .or_else(|| { + state + .surfaces + .get(surface) + .and_then(|surface| surface.resource_identity()) + .map(|identity| identity.tab_id.clone()) + }) + .with_context(|| format!("tab {surface} has no public identity"))?; + update_layout_undo_token_part(&mut hasher, tab_id.to_string().as_bytes()); + } + } + let confirmation_token = + hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::(); + let revision = registry.resource_topology_snapshot()?.revision; Ok(serde_json::json!({ - "op": "create-terminal", - "workspace_key": workspace_key, - "terminal_id": terminal_id, - "request_sha256": request_sha256, + "revision":revision.to_string(), + "confirmation_token":confirmation_token, + "closes_panes":closes_panes, })) } -fn terminal_lifecycle_name(lifecycle: TerminalLifecycle) -> &'static str { - match lifecycle { - TerminalLifecycle::Launching => "launching", - TerminalLifecycle::Adopting => "adopting", - TerminalLifecycle::Running => "running", - TerminalLifecycle::Exited => "exited", - TerminalLifecycle::Tombstoned => "tombstoned", +/// Advance the confirmation fence when a tab membership mutation touches a +/// pane that the latest undo would close. This runs in the same state-lock +/// critical section as the tab mutation, so a confirmed undo observes either +/// the old membership and revision or the new membership and revision. +fn fence_layout_undo_for_tab_membership(state: &mut State, panes: &[PaneId]) { + let screens = panes + .iter() + .filter_map(|pane| state.screen_of(*pane)) + .map(|(workspace, screen)| state.workspaces[workspace].screens[screen].id) + .collect::>(); + for screen_id in screens { + let Some(screen) = state + .workspaces + .iter_mut() + .flat_map(|workspace| workspace.screens.iter_mut()) + .find(|screen| screen.id == screen_id) + else { + continue; + }; + let affects_created_pane = screen.layout_undo.back().is_some_and(|entry| { + entry.after_revision == screen.layout_revision + && entry.created_panes.iter().any(|created| panes.contains(created)) + }); + if !affects_created_pane { + continue; + } + let revision = screen.layout_revision.saturating_add(1); + screen.layout_revision = revision; + let entry = screen.layout_undo.back_mut().expect("validated undo entry remains present"); + entry.after_revision = revision; + entry.coalesce = None; } } -fn run_placement_for_surface(state: &State, surface: SurfaceId) -> Option { - let pane = state.pane_of(surface)?; - let (workspace_index, screen_index) = state.screen_of(pane)?; - Some(RunPlacement { - surface, - pane, - screen: state.workspaces[workspace_index].screens[screen_index].id, - workspace: state.workspaces[workspace_index].id, - }) -} +/// Remove one surface from the state: detach it from its +/// pane, and collapse emptied panes/screens. Empty workspaces remain as +/// canonical registry entries. Returns the removed surface and whether +/// split ownership or positional indexes changed. Runs under the state lock. +fn remove_surface(mux: &Mux, state: &mut State, target: SurfaceId) -> (Option>, bool) { + let previous_active = state.active_pane(); + let removed = state.surfaces.remove(&target); + if let Some(tab_id) = state.resource_indexes.tab_ids.remove(&target) { + state.resource_indexes.tabs.remove(&tab_id); + } + if let Some(content_id) = state.resource_indexes.content_ids.remove(&target) { + let remove_content = if let Some(placements) = + state.resource_indexes.content_placements.get_mut(&content_id) + { + placements.retain(|placement| *placement != target); + placements.is_empty() + } else { + false + }; + if remove_content { + state.resource_indexes.content_placements.remove(&content_id); + } + } + state.resource_indexes.tab_pane.remove(&target); + let Some(pane_id) = state.pane_of(target) else { + return (removed, false); + }; + let pane = state.panes.get_mut(&pane_id).expect("pane_of returned live id"); + let idx = pane.tabs.iter().position(|id| *id == target).expect("tab in pane"); + pane.tabs.remove(idx); + if !pane.tabs.is_empty() { + if pane.active_tab >= idx && pane.active_tab > 0 { + pane.active_tab -= 1; + } + fence_layout_undo_for_tab_membership(state, &[pane_id]); + return (removed, false); + } -fn terminal_exit_snapshot_in_state( - registry: &WorkspaceRegistry, - state: &State, - terminal_id: &str, -) -> anyhow::Result { - let public_id = registry - .terminal_resource_id(terminal_id)? - .ok_or_else(|| anyhow::anyhow!("terminal {terminal_id} has no public resource id"))?; - let content_id = ContentPublicId::Terminal(public_id.clone()); - let topology = registry.resource_topology_snapshot()?; - let tab_ids = topology - .tabs - .iter() - .filter(|tab| tab.content_id == content_id) - .map(|tab| tab.public_id.clone()) - .collect::>(); - let surface = state.surface_by_content_public_id(&content_id); - let (cols, rows) = surface.map(|surface| surface.size()).unwrap_or((80, 24)); - let mut snapshot = serde_json::json!({ - "id": public_id, - "tab_id": tab_ids.first(), - "tab_ids": tab_ids, - "title": surface.map(|surface| surface.title()).unwrap_or_default(), - "cols": cols.max(1), - "rows": rows.max(1), - "running": false, - }); - if let Some(cwd) = surface.and_then(|surface| surface.spawn_cwd()) { - snapshot["cwd"] = serde_json::json!(cwd); + // Last tab gone: the pane collapses out of its screen. + state.remove_pane(pane_id); + let Some((wi, si)) = state.screen_of(pane_id) else { + return (removed, false); + }; + let (was_active, screen_remains) = { + let screen = &mut state.workspaces[wi].screens[si]; + let was_active = screen.active_pane == pane_id; + if screen.zoomed_pane == Some(pane_id) { + screen.zoomed_pane = None; + } + let screen_remains = remove_pane_from_screen_layout(mux, screen, pane_id); + (was_active, screen_remains) + }; + if screen_remains { + let next_active = if was_active { + let mut ids = Vec::new(); + state.workspaces[wi].screens[si].root.pane_ids(&mut ids); + most_recent_pane(state, &ids) + } else { + None + }; + if let Some(next) = next_active { + state.workspaces[wi].screens[si].active_pane = next; + } + stamp_changed_active_pane(mux, state, previous_active); + return (removed, true); } - Ok(snapshot) -} -type FocusIdentity = (WorkspaceId, ScreenId, PaneId); + // Screen emptied: drop it from the workspace. + let ws = &mut state.workspaces[wi]; + ws.screens.remove(si); + ws.active_screen = ws.active_screen.min(ws.screens.len().saturating_sub(1)); + if !ws.screens.is_empty() { + stamp_changed_active_pane(mux, state, previous_active); + return (removed, true); + } -fn current_focus_identity(state: &State) -> Option { - let workspace = state.workspaces.get(state.active_workspace)?; - let screen = workspace.active_screen_ref()?; - Some((workspace.id, screen.id, screen.active_pane)) + // The screen emptied, but the workspace remains as a canonical registry + // entry. Record the resulting loss of active pane without discarding its + // stable workspace identity. + stamp_changed_active_pane(mux, state, previous_active); + (removed, true) } -fn restore_focus_identity(state: &mut State, focus: Option) { - let Some((workspace_id, screen_id, pane_id)) = focus else { return }; - let Some(workspace_index) = state.workspace_index(workspace_id) else { return }; - state.active_workspace = workspace_index; - let Some(screen_index) = - state.workspaces[workspace_index].screens.iter().position(|screen| screen.id == screen_id) - else { +/// Transfer a topology-owned runtime into the shutdown ledger before the +/// state lock is released, so shutdown always sees one authoritative owner. +fn take_surface_for_retirement( + mux: &Mux, + state: &mut State, + target: SurfaceId, +) -> Option> { + let removed = state.surfaces.remove(&target); + if let Some(surface) = &removed { + let _ = mux.shutdown_owners.stage_surface(surface); + } + removed +} + +fn collapse_empty_pane(mux: &Mux, state: &mut State, pane_id: PaneId) { + state.remove_pane(pane_id); + let Some((wi, si)) = state.screen_of(pane_id) else { return; }; - state.workspaces[workspace_index].active_screen = screen_index; - if state.workspaces[workspace_index].screens[screen_index].root.contains(pane_id) { - state.workspaces[workspace_index].screens[screen_index].active_pane = pane_id; + let (was_active, screen_remains) = { + let screen = &mut state.workspaces[wi].screens[si]; + let was_active = screen.active_pane == pane_id; + if screen.zoomed_pane == Some(pane_id) { + screen.zoomed_pane = None; + } + let screen_remains = remove_pane_from_screen_layout(mux, screen, pane_id); + (was_active, screen_remains) + }; + if screen_remains { + let next_active = if was_active { + let mut ids = Vec::new(); + state.workspaces[wi].screens[si].root.pane_ids(&mut ids); + most_recent_pane(state, &ids) + } else { + None + }; + if let Some(next) = next_active { + state.workspaces[wi].screens[si].active_pane = next; + } + } else { + let ws = &mut state.workspaces[wi]; + ws.screens.remove(si); + ws.active_screen = ws.active_screen.min(ws.screens.len().saturating_sub(1)); } } -fn commit_terminal_transition( - registry: &mut WorkspaceRegistry, - event_kind: &str, - operation: &str, - terminal: &RegistryTerminal, -) -> anyhow::Result { - let mutation = WorkspaceMutation::local("cmux-tui-runtime"); - let commit = registry.commit_terminal( - &mutation, - &serde_json::json!({ - "op": operation, - "terminal_id": terminal.terminal_id, - "workspace_key": terminal.workspace_key, - "incarnation": terminal.incarnation, - "lifecycle": terminal.lifecycle, - }), - None, - None, - event_kind, - terminal, - &serde_json::json!({ - "terminal_id": terminal.terminal_id, - "workspace_key": terminal.workspace_key, - "incarnation": terminal.incarnation, - "state": terminal.lifecycle, - }), - )?; - Ok(commit.revision) -} +fn move_tab_in_state( + mux: &Mux, + state: &mut State, + surface: SurfaceId, + target_pane: PaneId, + index: usize, +) -> (bool, bool) { + if !state.surfaces.contains_key(&surface) || !state.panes.contains_key(&target_pane) { + return (false, false); + } + let Some(source_pane) = state.pane_of(surface) else { return (false, false) }; + if source_pane == target_pane { + let Some(pane) = state.panes.get_mut(&target_pane) else { + return (false, false); + }; + let Some(old_idx) = pane.tabs.iter().position(|id| *id == surface) else { + return (false, false); + }; + let new_idx = if index > old_idx { index.saturating_sub(1) } else { index }; + let new_idx = new_idx.min(pane.tabs.len().saturating_sub(1)); + if new_idx == old_idx { + return (false, false); + } + let tab = pane.tabs.remove(old_idx); + pane.tabs.insert(new_idx, tab); + pane.active_tab = new_idx; + fence_layout_undo_for_tab_membership(state, &[target_pane]); + return (true, false); + } -/// Advance only renderer lifecycle fields from the latest durable row. This -/// deliberately re-reads under the registry writer mutex and uses the -/// terminal revision as a CAS: a GUI move committed while a host launch or -/// adoption was in flight can never be overwritten by a stale row clone. -fn commit_terminal_lifecycle( - registry: &mut WorkspaceRegistry, - event_kind: &str, - operation: &str, - terminal_id: &str, - lifecycle: TerminalLifecycle, - incarnation: Option<&str>, - exit: Option, -) -> anyhow::Result<(RegistryTerminal, u64)> { - let snapshot = registry.terminal_snapshot()?; - let mut terminal = registry - .terminal_record(terminal_id)? - .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; - terminal.lifecycle = lifecycle; - if let Some(incarnation) = incarnation { - terminal.incarnation = Some(incarnation.to_string()); + fence_layout_undo_for_tab_membership(state, &[source_pane, target_pane]); + { + let Some(source) = state.panes.get_mut(&source_pane) else { + return (false, false); + }; + let Some(old_idx) = source.tabs.iter().position(|id| *id == surface) else { + return (false, false); + }; + source.tabs.remove(old_idx); + if !source.tabs.is_empty() && source.active_tab >= old_idx && source.active_tab > 0 { + source.active_tab -= 1; + } } - terminal.exit = exit; - let mutation = WorkspaceMutation::local("cmux-tui-runtime"); - let commit = registry.commit_terminal( - &mutation, - &serde_json::json!({ - "op": operation, - "terminal_id": terminal.terminal_id, - "incarnation": terminal.incarnation, - "lifecycle": terminal.lifecycle, - }), - Some(&snapshot.generation), - Some(snapshot.revision), - event_kind, - &terminal, - &serde_json::json!({ - "terminal_id": terminal.terminal_id, - "workspace_key": terminal.workspace_key, - "incarnation": terminal.incarnation, - "state": terminal.lifecycle, - }), - )?; - Ok((terminal, commit.revision)) -} -#[cfg(test)] -fn commit_terminal_workspace( - registry: &mut WorkspaceRegistry, - terminal_id: &str, - workspace_key: &str, -) -> anyhow::Result { - let snapshot = registry.terminal_snapshot()?; - let mut terminal = registry - .terminal_record(terminal_id)? - .ok_or_else(|| anyhow::anyhow!("unknown terminal {terminal_id}"))?; - if terminal.lifecycle == TerminalLifecycle::Tombstoned { - anyhow::bail!("terminal is already closed"); + let topology_changed = state.panes.get(&source_pane).is_some_and(|pane| pane.tabs.is_empty()); + if topology_changed { + collapse_empty_pane(mux, state, source_pane); } - terminal.workspace_key = workspace_key.to_string(); - let mutation = WorkspaceMutation::local("cmux-tui-runtime"); - let commit = registry.commit_terminal( - &mutation, - &serde_json::json!({ - "op":"move-terminal", - "terminal_id":terminal_id, - "workspace_key":workspace_key, - }), - Some(&snapshot.generation), - Some(snapshot.revision), - "terminal-moved", - &terminal, - &serde_json::json!({ - "terminal_id":terminal_id, - "workspace_key":workspace_key, - "incarnation":terminal.incarnation, - "state":terminal.lifecycle, - }), - )?; - Ok(commit.revision) -} -#[cfg(unix)] -fn terminate_host_record( - record: crate::terminal_host_runtime::TerminalHostRecord, - record_path: std::path::PathBuf, -) -> bool { - let Ok(mut host) = crate::terminal_host_runtime::adopt_terminal_host(record, record_path) - else { - return false; + let Some(target) = state.panes.get_mut(&target_pane) else { + return (false, topology_changed); }; - let exit_path = host.exit_record_path(); - let exit = host.terminate_and_wait_for_exit(); - host.disconnect(); - let Ok(exit) = exit else { return false }; - acknowledge_exact_terminal_host_exit(&exit_path, &exit) -} - -#[cfg(unix)] -fn acknowledge_exact_terminal_host_exit( - exit_path: &Path, - exit: &crate::terminal_host_runtime::TerminalHostExitRecord, -) -> bool { - match crate::terminal_host_runtime::acknowledge_terminal_host_exit_record(exit_path, exit) { - Ok(true) => true, - Ok(false) => !exit_path.exists(), - Err(_) => false, + let new_idx = index.min(target.tabs.len()); + target.tabs.insert(new_idx, surface); + target.active_tab = new_idx; + state.resource_indexes.tab_pane.insert(surface, target_pane); + let destination_path = if let Some((wi, si)) = state.screen_of(target_pane) { + state.active_workspace = wi; + let ws = &mut state.workspaces[wi]; + ws.active_screen = si; + let screen = &mut ws.screens[si]; + screen.active_pane = target_pane; + Some((ws.id, screen.id)) + } else { + None + }; + if let Some((workspace, screen)) = destination_path { + mux.subscribers.update_surface_session_path(surface, workspace, screen, target_pane); } + (true, topology_changed) } -#[cfg(unix)] -fn acknowledge_terminal_exit_sidecar( - record_path: &Path, - terminal_id: &str, - incarnation: Option<&str>, -) -> bool { - let exit_path = record_path.with_extension("exit"); - let exit = match crate::terminal_host_runtime::terminal_host_exit_record(record_path) { - Ok(Some((_, exit))) => exit, - Ok(None) => return !exit_path.exists(), - Err(_) => return false, +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + use crate::layout::{DEFAULT_VIEWPORT_PANE_WIDTH, VirtualRect}; + use crate::resource::{BrowserPublicId, MachinePublicId, SessionPublicId, TabPublicId}; + use crate::workspace_registry::{ + RegistryPane, RegistryScreen, RegistryViewportColumn, ResourceChange, ResourcePatch, }; - if exit.terminal_id != terminal_id - || incarnation.is_some_and(|expected| exit.incarnation != expected) - { - return false; - } - match crate::terminal_host_runtime::acknowledge_terminal_host_exit_record(&exit_path, &exit) { - Ok(true) => true, - Ok(false) => !exit_path.exists(), - Err(_) => false, + + fn test_mux() -> Arc { + Mux::new_for_test("test", SurfaceOptions::default()) } -} -#[cfg(unix)] -fn schedule_terminal_host_record_cleanup( - record: crate::terminal_host_runtime::TerminalHostRecord, - record_path: std::path::PathBuf, -) { - let fallback_record = record.clone(); - let fallback_path = record_path.clone(); - let name = format!("terminal-clean-{}", record.terminal_id); - if std::thread::Builder::new() - .name(name) - .spawn(move || retry_terminal_host_record_cleanup(record, record_path)) - .is_err() - { - // Thread exhaustion cannot turn a durable close into a permanent - // orphan. The request may remain blocked, but the exact host keeps - // being reconciled until it accepts termination or proves itself dead. - retry_terminal_host_record_cleanup(fallback_record, fallback_path); + fn assert_terminal_view_detached(mux: &Mux, surface: SurfaceId) { + assert!(!mux.with_state(|state| state.surfaces.contains_key(&surface))); + assert!(mux.surface(surface).is_some(), "terminal runtime must remain catalog-owned"); } -} -#[cfg(unix)] -fn retry_terminal_host_record_cleanup( - record: crate::terminal_host_runtime::TerminalHostRecord, - record_path: std::path::PathBuf, -) { - let mut delay = Duration::from_millis(25); - loop { - std::thread::sleep(delay); - if cleanup_terminal_host_record(&record, &record_path) { - return; - } - delay = (delay * 2).min(Duration::from_secs(5)); + fn wait_for_kitty_image_budget(mux: &Mux) { + // The stress cases create every process-budget owner while the rest + // of this 800-test binary is also scheduling worker threads. Wait on + // the worker's state transition instead of polling CPU progress. + assert!( + mux.wait_for_kitty_image_budget_idle_for_test( + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT.saturating_mul(15), + ), + "Kitty image budget worker did not converge" + ); } -} -#[cfg(unix)] -fn terminal_host_record_liveness( - record_path: &Path, - record: &crate::terminal_host_runtime::TerminalHostRecord, -) -> TerminalHostLiveness { - crate::terminal_host_runtime::terminal_host_record_liveness(record_path, record) - .unwrap_or(TerminalHostLiveness::Indeterminate) -} + fn close_terminal_runtime_for_test(mux: &Mux, surface: &Surface) { + let identity = mux + .resource_terminal_host_identity(surface) + .expect("test terminal has a host identity"); + mux.close_terminal(&identity.terminal_id, &identity.incarnation).unwrap(); + } -/// Ask a host to terminate first; if its admin socket is unavailable, remove -/// discovery artifacts only when the process-start nonce positively proves -/// this exact incarnation is dead. `false` means the live/ambiguous record is -/// deliberately retained for a later retry. -#[cfg(unix)] -fn cleanup_terminal_host_record( - record: &crate::terminal_host_runtime::TerminalHostRecord, - record_path: &Path, -) -> bool { - match terminal_host_record_liveness(record_path, record) { - TerminalHostLiveness::Dead => { - match crate::terminal_host_runtime::remove_stale_terminal_host_record( - record_path, - record, - ) { - Ok(removed) => removed, - // Positive nonce/PID death proof remains authoritative when - // the host already removed its own record between probe and - // compare-delete. - Err(_) if !record_path.exists() => true, - Err(_) => false, - } - } - TerminalHostLiveness::Live | TerminalHostLiveness::Indeterminate => { - terminate_host_record(record.clone(), record_path.to_path_buf()) + fn public_request( + mux: &Arc, + id: &str, + operation: &str, + params: Value, + idempotency_key: Option<&str>, + ) -> Value { + let mut request = serde_json::json!({ + "protocol":"cmux.protocol/2", + "type":"request", + "id":id, + "operation":operation, + "params":params, + }); + if let Some(idempotency_key) = idempotency_key { + request["idempotency_key"] = Value::String(idempotency_key.to_string()); } + crate::resource_router::handle_resource_message(mux, &request.to_string()).unwrap() + } + + fn state_topology_fingerprint(state: &State) -> String { + let workspaces = state + .workspaces + .iter() + .map(|workspace| { + ( + workspace.id, + workspace.public_id.clone(), + workspace.key.clone(), + workspace.name.clone(), + workspace.active_screen, + workspace + .screens + .iter() + .map(|screen| { + ( + screen.id, + screen.public_id.clone(), + screen.layout_revision, + format!("{:?}", screen.layout_snapshot()), + format!("{:?}", screen.layout_undo), + ) + }) + .collect::>(), + ) + }) + .collect::>(); + let mut panes = state + .panes + .iter() + .map(|(id, pane)| (*id, pane.tabs.clone(), pane.active_tab)) + .collect::>(); + panes.sort_by_key(|(id, _, _)| *id); + let mut surfaces = state.surfaces.keys().copied().collect::>(); + surfaces.sort_unstable(); + format!( + "{:?}", + ( + state.resource_revision, + state.workspace_revision, + state.active_workspace, + workspaces, + panes, + surfaces, + ) + ) } -} -fn insert_surface_checked(state: &mut State, surface: Arc) -> anyhow::Result<()> { - if state.surfaces.contains_key(&surface.id) { - anyhow::bail!("duplicate_surface_id"); + fn restore_workspace_id(value: u128) -> WorkspacePublicId { + WorkspacePublicId::parse(format!("ws_{value:032x}")).unwrap() } - if let Some(expected_tab) = state.resource_indexes.tab_ids.get(&surface.id) { - let expected_content = state - .resource_indexes - .content_ids - .get(&surface.id) - .ok_or_else(|| anyhow::anyhow!("reserved tab has no content identity"))?; - let actual = surface - .resource_identity() - .ok_or_else(|| anyhow::anyhow!("public tab slot received auxiliary content"))?; - if &actual.tab_id != expected_tab || &actual.content_id != expected_content { - anyhow::bail!("surface resource identity does not match its reserved tab slot"); - } - } else if let Some(identity) = surface.resource_identity() { - if state.resource_indexes.tabs.contains_key(&identity.tab_id) { - anyhow::bail!("duplicate_tab_id"); - } - if matches!(identity.content_id, ContentPublicId::Browser(_)) - && state.resource_indexes.content_placements.contains_key(&identity.content_id) - { - anyhow::bail!("duplicate_content_id"); - } + + fn restore_screen_id(value: u128) -> ScreenPublicId { + ScreenPublicId::parse(format!("screen_{value:032x}")).unwrap() } - if let Some(identity) = surface.resource_identity() - && let ContentPublicId::Terminal(terminal_id) = &identity.content_id - { - anyhow::ensure!( - surface.terminal_public_id() == Some(terminal_id), - "terminal placement identity does not match its runtime" - ); + + fn restore_pane_id(value: u128) -> PanePublicId { + PanePublicId::parse(format!("pane_{value:032x}")).unwrap() } - register_terminal_runtime_checked(state, &surface)?; - state.surfaces.insert(surface.id, surface); - Ok(()) -} -fn insert_terminal_runtime_checked(state: &mut State, surface: Arc) -> anyhow::Result<()> { - anyhow::ensure!(surface.kind() == SurfaceKind::Pty, "terminal catalog requires a PTY"); - anyhow::ensure!( - surface.resource_identity().is_none(), - "unplaced terminal runtime cannot carry a tab identity" - ); - register_terminal_runtime_checked(state, &surface) -} + fn restore_tab_id(value: u128) -> TabPublicId { + TabPublicId::parse(format!("tab_{value:032x}")).unwrap() + } -fn insert_restored_terminal_runtime_checked( - state: &mut State, - surface: Arc, -) -> anyhow::Result<()> { - let terminal_id = surface - .terminal_public_id() - .cloned() - .context("restored terminal omitted its public content identity")?; - let content_id = ContentPublicId::Terminal(terminal_id); - let placements = state.placements_of_content(&content_id).to_vec(); - if placements.is_empty() { - return insert_terminal_runtime_checked(state, surface); + fn restore_browser_id(value: u128) -> BrowserPublicId { + BrowserPublicId::parse(format!("browser_{value:032x}")).unwrap() } - anyhow::ensure!( - placements.contains(&surface.id), - "restored terminal runtime is not one of its durable placements" - ); - insert_surface_checked(state, surface.clone())?; - for placement in placements.into_iter().filter(|placement| *placement != surface.id) { - anyhow::ensure!( - !state.surfaces.contains_key(&placement), - "restored terminal placement is already materialized" - ); - let tab_id = state - .resource_indexes - .tab_ids - .get(&placement) - .cloned() - .context("restored terminal placement has no tab identity")?; - let projected = surface - .project_terminal(placement, TabResourceIdentity::new(tab_id, content_id.clone()))?; - insert_surface_checked(state, projected)?; + fn restore_terminal_id(value: u128) -> TerminalPublicId { + TerminalPublicId::parse(format!("term_{value:032x}")).unwrap() + } + + fn restore_split_id(value: u128) -> SplitPublicId { + SplitPublicId::parse(format!("split_{value:032x}")).unwrap() + } + + fn resource_restore_fixture() -> (RegistrySnapshot, ResourceTopologySnapshot) { + let first_workspace = RegistryWorkspace { + id: 10, + public_id: restore_workspace_id(1), + key: "first".into(), + name: "Duplicate".into(), + group_key: "test".into(), + }; + let empty_workspace = RegistryWorkspace { + id: 20, + public_id: restore_workspace_id(2), + key: "empty".into(), + name: "Duplicate".into(), + group_key: "test".into(), + }; + let first_screen = restore_screen_id(1); + let second_screen = restore_screen_id(2); + let panes = (1..=5).map(restore_pane_id).collect::>(); + let tabs = (1..=6).map(restore_tab_id).collect::>(); + let internal_split = restore_split_id(1); + let boundary_split = restore_split_id(2); + let base_column = restore_split_id(3); + let first_column_layout = RegistryLayoutNode::Split { + split: internal_split, + direction: "down".into(), + ratio: 0.6, + first: Box::new(RegistryLayoutNode::Stack { + panes: vec![panes[0].clone(), panes[1].clone()], + expanded: panes[1].clone(), + }), + second: Box::new(RegistryLayoutNode::Leaf { pane: panes[2].clone() }), + }; + let first_layout = RegistryLayoutNode::Split { + split: boundary_split.clone(), + direction: "right".into(), + ratio: 0.8 / (0.8 + 0.4), + first: Box::new(first_column_layout.clone()), + second: Box::new(RegistryLayoutNode::Leaf { pane: panes[3].clone() }), + }; + let screens = vec![ + RegistryScreen { + public_id: first_screen.clone(), + workspace_id: first_workspace.public_id.clone(), + position: 0, + name: Some("Columns".into()), + layout: first_layout, + active_pane: panes[2].clone(), + zoomed_pane: Some(panes[2].clone()), + auto_layout: None, + viewport: RegistryViewport { + base_width: Some(0.8), + columns: vec![ + RegistryViewportColumn { + id: base_column, + width: 0.8, + layout: first_column_layout, + auto_layout: None, + }, + RegistryViewportColumn { + id: boundary_split, + width: 0.4, + layout: RegistryLayoutNode::Leaf { pane: panes[3].clone() }, + auto_layout: Some(vec![panes[3].clone()]), + }, + ], + }, + }, + RegistryScreen { + public_id: second_screen.clone(), + workspace_id: first_workspace.public_id.clone(), + position: 1, + name: Some("Selected".into()), + layout: RegistryLayoutNode::Leaf { pane: panes[4].clone() }, + active_pane: panes[4].clone(), + zoomed_pane: Some(panes[4].clone()), + auto_layout: Some(vec![panes[4].clone()]), + viewport: RegistryViewport::default(), + }, + ]; + let registry_panes = vec![ + RegistryPane { + public_id: panes[0].clone(), + screen_id: first_screen.clone(), + name: Some("one".into()), + active_tab: Some(tabs[0].clone()), + creation_ordinal: 1, + }, + RegistryPane { + public_id: panes[1].clone(), + screen_id: first_screen.clone(), + name: Some("two".into()), + active_tab: Some(tabs[1].clone()), + creation_ordinal: 2, + }, + RegistryPane { + public_id: panes[2].clone(), + screen_id: first_screen.clone(), + name: Some("three".into()), + active_tab: Some(tabs[2].clone()), + creation_ordinal: 3, + }, + RegistryPane { + public_id: panes[3].clone(), + screen_id: first_screen, + name: Some("four".into()), + active_tab: Some(tabs[3].clone()), + creation_ordinal: 4, + }, + RegistryPane { + public_id: panes[4].clone(), + screen_id: second_screen.clone(), + name: Some("five".into()), + active_tab: Some(tabs[5].clone()), + creation_ordinal: 5, + }, + ]; + let registry_tabs = tabs + .iter() + .enumerate() + .map(|(index, tab)| { + let pane_index = index.min(4); + RegistryTab { + public_id: tab.clone(), + pane_id: panes[pane_index].clone(), + position: usize::from(index == 5), + content_id: ContentPublicId::Browser(restore_browser_id(index as u128 + 1)), + name: Some(format!("tab-{index}")), + browser_url: Some(format!("about:blank#{index}")), + terminal_id: None, + } + }) + .collect::>(); + let browsers = registry_tabs + .iter() + .enumerate() + .map(|(index, tab)| { + let ContentPublicId::Browser(public_id) = &tab.content_id else { + unreachable!("restart fixture uses browser tabs"); + }; + RegistryBrowser { + public_id: public_id.clone(), + url: tab.browser_url.clone().unwrap(), + source: if index % 2 == 0 { + crate::workspace_registry::RegistryBrowserSource::Launched + } else { + crate::workspace_registry::RegistryBrowserSource::External + }, + launch: if index % 2 == 0 { + crate::workspace_registry::RegistryBrowserLaunch::Create + } else { + crate::workspace_registry::RegistryBrowserLaunch::Adopted + }, + reconnect: RegistryBrowserReconnect::Recreate, + status: if index % 2 == 0 { + crate::workspace_registry::RegistryBrowserStatus::Live + } else { + crate::workspace_registry::RegistryBrowserStatus::Failed + }, + cols: 90 + index as u16, + rows: 30 + index as u16, + } + }) + .collect(); + let session_id = + SessionPublicId::parse("session_00000000000000000000000000000001").unwrap(); + ( + RegistrySnapshot { + registry_id: "registry".into(), + generation: "generation".into(), + revision: 1, + resource_revision: 1, + session_id: session_id.clone(), + next_numeric_id: 100, + workspaces: vec![first_workspace.clone(), empty_workspace.clone()], + }, + ResourceTopologySnapshot { + session_id, + generation: "generation".into(), + revision: 1, + active_workspace: Some(empty_workspace.public_id.clone()), + active_screens: vec![ + (first_workspace.public_id, Some(second_screen)), + (empty_workspace.public_id, None), + ], + screens, + panes: registry_panes, + tabs: registry_tabs, + browsers, + }, + ) } - Ok(()) -} -fn register_terminal_runtime_checked( - state: &mut State, - surface: &Arc, -) -> anyhow::Result<()> { - let Some(terminal_id) = surface.terminal_public_id() else { - return Ok(()); - }; - let runtime_id = surface - .terminal_runtime_id() - .context("terminal content identity requires a PTY runtime")?; - if let Some(existing) = state.terminal_catalog_by_runtime.get(&runtime_id) { - anyhow::ensure!(existing == terminal_id, "terminal runtime has two content identities"); - } - if let Some(existing) = state.terminal_catalog.get(terminal_id) { - anyhow::ensure!( - existing.shares_terminal_runtime(surface), - "terminal content identity points at two runtimes" - ); - } else { - if let Some(identity) = surface.terminal_host_identity() { - for existing in state.terminal_catalog.values().filter(|existing| { - existing - .terminal_host_identity() - .is_some_and(|candidate| candidate.terminal_id == identity.terminal_id) - }) { - anyhow::ensure!(existing.shares_terminal_runtime(surface), "duplicate_terminal_id"); - } - } - state.terminal_catalog.insert(terminal_id.clone(), surface.clone()); + fn selector_fixture() + -> (RestoredResourceState, MachinePublicId, SessionPublicId, ResourceTopologySnapshot) { + let (snapshot, topology) = resource_restore_fixture(); + let session = snapshot.session_id.clone(); + let restored = restore_resource_state(snapshot, topology.clone()).unwrap(); + (restored, MachinePublicId::random().unwrap(), session, topology) } - state.terminal_catalog_by_runtime.insert(runtime_id, terminal_id.clone()); - Ok(()) -} -/// Remove a terminal catalog owner and every view that projects it. Durable -/// exit and explicit close share this path so multiview teardown cannot leave -/// a reverse catalog entry or a secondary placement behind. The runtime is -/// optional because restored topology exists before host adoption. -fn remove_terminal_content_from_state( - mux: &Mux, - state: &mut State, - terminal_id: &TerminalPublicId, -) -> (Option>, Vec>, bool) { - let runtime = state.terminal_catalog.remove(terminal_id); - if let Some(runtime_id) = runtime.as_ref().and_then(|runtime| runtime.terminal_runtime_id()) { - state.terminal_catalog_by_runtime.remove(&runtime_id); - } - let mut targets = - state.placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())).to_vec(); - if let Some(runtime_id) = runtime.as_ref().and_then(|runtime| runtime.terminal_runtime_id()) { - targets.extend(state.surfaces.iter().filter_map(|(placement, candidate)| { - (candidate.terminal_runtime_id() == Some(runtime_id)).then_some(*placement) - })); - } - targets.sort_unstable(); - targets.dedup(); - let mut removed = Vec::with_capacity(targets.len()); - let mut split_index_dirty = false; - for target in targets { - let (candidate, topology_changed) = remove_surface(mux, state, target); - split_index_dirty |= topology_changed; - if let Some(candidate) = candidate { - removed.push(candidate); + fn routed_selectors( + machine: &MachinePublicId, + session: &SessionPublicId, + ) -> crate::ResourceSelectors { + crate::ResourceSelectors { + machine: Some(machine.to_string()), + session: Some(session.to_string()), + ..crate::ResourceSelectors::default() } } - if split_index_dirty { - Mux::rebuild_split_screen_index(state); - } - (runtime, removed, split_index_dirty) -} - -/// Remove one known terminal runtime and every placement that projects it. -/// Ordinary topology removal must leave the catalog owner alive so a terminal -/// can have zero views and be projected again later. -fn remove_terminal_runtime_from_state( - mux: &Mux, - state: &mut State, - runtime: &Surface, -) -> (Vec>, bool) { - let Some(terminal_id) = runtime.terminal_public_id().cloned() else { - return (Vec::new(), false); - }; - if !state - .terminal_catalog - .get(&terminal_id) - .is_some_and(|catalogued| catalogued.shares_terminal_runtime(runtime)) - { - return (Vec::new(), false); - } - let (_, removed, split_index_dirty) = - remove_terminal_content_from_state(mux, state, &terminal_id); - (removed, split_index_dirty) -} -fn validate_terminal_hex(value: &str, error: &'static str) -> anyhow::Result<()> { - if TerminalId::from_hex(value).is_none() { - anyhow::bail!(error); - } - Ok(()) -} + #[test] + fn direct_browser_id_derives_its_complete_path_without_structural_selectors() { + let (restored, machine, session, topology) = selector_fixture(); + let browser = topology.browsers[0].public_id.clone(); + let tab = topology + .tabs + .iter() + .find(|tab| tab.content_id == ContentPublicId::Browser(browser.clone())) + .unwrap(); + let pane = topology.panes.iter().find(|pane| pane.public_id == tab.pane_id).unwrap(); + let screen = + topology.screens.iter().find(|screen| screen.public_id == pane.screen_id).unwrap(); + let mut selectors = routed_selectors(&machine, &session); + selectors.browser = Some(browser.to_string()); -fn unique_terminal_match( - terminal_id: &str, - identities: impl IntoIterator, -) -> anyhow::Result> { - let mut found = None; - for (value, identity) in identities { - if identity.terminal_id != terminal_id { - continue; - } - anyhow::ensure!(found.is_none(), "duplicate_terminal_id"); - found = Some((value, identity)); + let resolved = resolve_resource_selectors( + &restored.state, + ResourceSelectorContext { + machine_id: &machine, + machine_name: None, + session_id: &session, + session_name: "test", + }, + crate::ResourceTarget::Browser, + &selectors, + ) + .unwrap() + .path; + assert_eq!(resolved.workspace, Some(screen.workspace_id.clone())); + assert_eq!(resolved.screen, Some(screen.public_id.clone())); + assert_eq!(resolved.pane, Some(pane.public_id.clone())); + assert_eq!(resolved.tab, Some(tab.public_id.clone())); + assert_eq!(resolved.browser, Some(browser)); } - Ok(found) -} - -/// Return one materialized view of `runtime`. The public reverse index is the -/// steady-state fast path. The runtime scan also admits a newly-created view -/// before it has been inserted into a pane: creation must bind that reserved -/// tab exactly once, while a detached zero-view terminal has no entry in -/// `state.surfaces` and therefore remains detached. -fn terminal_placement_for_runtime(state: &State, runtime: &Surface) -> Option { - let terminal_id = runtime.terminal_public_id()?; - state - .placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())) - .iter() - .copied() - .find(|placement| { - state - .surfaces - .get(placement) - .is_some_and(|surface| surface.shares_terminal_runtime(runtime)) - }) - .or_else(|| { - state - .surfaces - .iter() - .filter_map(|(placement, surface)| { - (surface.shares_terminal_runtime(runtime) - && surface.resource_identity().is_some()) - .then_some(*placement) - }) - .min() - }) -} - -/// Return one representative for each content runtime plus every nonterminal -/// surface. Runtime-wide mutations must not repeat host I/O or render work for -/// every terminal view, and catalog-only terminals still need those updates. -fn unique_surface_runtimes(state: &State) -> Vec> { - let mut seen_terminals = HashSet::new(); - state - .terminal_catalog - .values() - .chain(state.surfaces.values()) - .filter(|surface| { - surface.terminal_runtime_id().is_none_or(|runtime| seen_terminals.insert(runtime)) - }) - .cloned() - .collect() -} -fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or(0) -} + #[test] + fn terminal_selector_uses_ancestor_scope_and_rejects_scope_after_last_view_closes() { + let mux = test_mux(); + let source = mux.new_workspace(Some("source".into()), None).unwrap(); + let destination_anchor = mux.new_workspace(Some("destination".into()), None).unwrap(); + let terminal_id = source.terminal_public_id().cloned().unwrap(); + let (source_workspace, source_pane, destination_workspace, destination_pane) = mux + .with_state(|state| { + let source_pane = state.pane_of(source.id).unwrap(); + let destination_pane = state.pane_of(destination_anchor.id).unwrap(); + let source_workspace = + state.workspaces[state.screen_of(source_pane).unwrap().0].public_id.clone(); + let destination_workspace = state.workspaces + [state.screen_of(destination_pane).unwrap().0] + .public_id + .clone(); + (source_workspace, source_pane, destination_workspace, destination_pane) + }); + // Keep both workspaces live after every view of `terminal_id` closes. + mux.new_tab(Some(source_pane), None, None).unwrap(); -fn sidebar_retry_delay(failures: u32) -> Duration { - let shift = failures.saturating_sub(1).min(5); - Duration::from_secs(1u64 << shift) -} + let terminal_selectors = crate::ResourceSelectors { + terminal: Some(terminal_id.to_string()), + ..Mux::ordinary_resource_selectors() + }; + mux.resource_project_terminal_selected( + terminal_selectors.clone(), + mux.ordinary_pane_selectors(destination_pane).unwrap(), + usize::MAX, + Some("second view".into()), + None, + &WorkspaceMutation::local("selector-multiview"), + ) + .unwrap(); -impl Drop for Mux { - fn drop(&mut self) { - if let Ok(state) = self.state.get_mut() { - let deadline = Instant::now() + TERMINAL_READER_SHUTDOWN_TIMEOUT; - for surface in unique_surface_runtimes(state) { - let _ = surface.shutdown_for_daemon(deadline); - } - } - self.journal_kernel.shutdown(); - if let Ok(runtime) = self.browser_runtime.get_mut() - && let Some(runtime) = runtime.take() - { - runtime.shutdown(); + for expected_workspace in [&source_workspace, &destination_workspace] { + let mut scoped = terminal_selectors.clone(); + scoped.workspace = Some(expected_workspace.to_string()); + let path = mux.resolve_resource_path(crate::ResourceTarget::Terminal, &scoped).unwrap(); + assert_eq!(path.workspace.as_ref(), Some(expected_workspace)); + assert_eq!(path.terminal.as_ref(), Some(&terminal_id)); } - } -} - -fn restore_resource_state( - snapshot: RegistrySnapshot, - topology: ResourceTopologySnapshot, -) -> anyhow::Result { - anyhow::ensure!( - topology.session_id == snapshot.session_id, - "resource topology belongs to a different session" - ); - anyhow::ensure!( - topology.generation == snapshot.generation, - "resource topology generation changed during startup" - ); - anyhow::ensure!( - topology.revision == snapshot.resource_revision, - "resource topology revision changed during startup" - ); - let mut next_id = snapshot.next_numeric_id.max(1); - let mut allocate = || -> anyhow::Result { - let id = next_id; - next_id = - next_id.checked_add(1).ok_or_else(|| anyhow::anyhow!("runtime id space exhausted"))?; - Ok(id) - }; + let placements = mux.with_state(|state| { + state.placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())).to_vec() + }); + assert_eq!(placements.len(), 2); + for placement in placements { + assert!(mux.close_surface(placement).unwrap()); + } - let workspace_revision = snapshot.revision; - let resource_revision = snapshot.resource_revision; - let mut workspaces = snapshot - .workspaces - .into_iter() - .map(|workspace| Workspace { - id: workspace.id, - public_id: workspace.public_id, - key: workspace.key, - name: workspace.name, - screens: Vec::new(), - active_screen: 0, - }) - .collect::>(); - let workspace_index_by_public = workspaces - .iter() - .enumerate() - .map(|(index, workspace)| (workspace.public_id.clone(), index)) - .collect::>(); + let path = mux + .resolve_resource_path(crate::ResourceTarget::Terminal, &terminal_selectors) + .unwrap(); + assert_eq!(path.workspace, None); + assert_eq!(path.tab, None); + assert_eq!(path.terminal, Some(terminal_id)); - let mut screen_slots = HashMap::new(); - for screen in &topology.screens { - let old = screen_slots.insert(screen.public_id.clone(), allocate()?); - anyhow::ensure!(old.is_none(), "duplicate screen {}", screen.public_id); - } - let mut pane_slots = HashMap::new(); - for pane in &topology.panes { - let old = pane_slots.insert(pane.public_id.clone(), allocate()?); - anyhow::ensure!(old.is_none(), "duplicate pane {}", pane.public_id); - } - let mut tab_slots = HashMap::new(); - for tab in &topology.tabs { - let old = tab_slots.insert(tab.public_id.clone(), allocate()?); - anyhow::ensure!(old.is_none(), "duplicate tab {}", tab.public_id); + let mut scoped = terminal_selectors; + scoped.workspace = Some(destination_workspace.to_string()); + let error = + mux.resolve_resource_path(crate::ResourceTarget::Terminal, &scoped).unwrap_err(); + assert_eq!(error.code, "selector.wrong_parent"); + assert_eq!(error.details["scope"], "terminal"); + assert_eq!(error.details["actual_parent"], ""); } - let mut indexes = PublicSlotIndexes::default(); - for workspace in &workspaces { - anyhow::ensure!( - indexes.workspaces.insert(workspace.public_id.clone(), workspace.id).is_none(), - "duplicate workspace {}", - workspace.public_id - ); - indexes.workspace_ids.insert(workspace.id, workspace.public_id.clone()); - } - for (public_id, slot) in &screen_slots { - indexes.screens.insert(public_id.clone(), *slot); - indexes.screen_ids.insert(*slot, public_id.clone()); - } - for (public_id, slot) in &pane_slots { - indexes.panes.insert(public_id.clone(), *slot); - indexes.pane_ids.insert(*slot, public_id.clone()); - } + #[test] + fn terminal_projection_event_matches_every_changed_public_snapshot() { + let mux = test_mux(); + let source = mux.new_workspace(Some("source".into()), None).unwrap(); + let destination = mux.new_workspace(Some("destination".into()), None).unwrap(); + let destination_pane = mux.with_state(|state| state.pane_of(destination.id).unwrap()); + mux.new_tab(Some(destination_pane), None, None).unwrap(); + let terminal_id = source.terminal_public_id().cloned().unwrap(); + let destination_selectors = mux.ordinary_pane_selectors(destination_pane).unwrap(); + let destination_pane_id = destination_selectors.pane.clone().unwrap(); + let before = mux.with_state(|state| state.resource_revision); - let mut browsers_by_id = topology - .browsers - .iter() - .cloned() - .map(|browser| (browser.public_id.clone(), browser)) - .collect::>(); - anyhow::ensure!( - browsers_by_id.len() == topology.browsers.len(), - "resource topology contains duplicate browser metadata" - ); - let mut tabs_by_pane = HashMap::>::new(); - let mut contents = Vec::with_capacity(topology.tabs.len()); - for tab in topology.tabs { - let browser = match (&tab.content_id, &tab.browser_url, &tab.terminal_id) { - (ContentPublicId::Terminal(_), None, Some(_)) => None, - (ContentPublicId::Browser(browser_id), Some(url), None) => { - let browser = browsers_by_id.remove(browser_id).ok_or_else(|| { - anyhow::anyhow!("browser tab {} has no restart metadata", tab.public_id) - })?; - anyhow::ensure!( - &browser.url == url, - "browser tab {} URL disagrees with its restart metadata", - tab.public_id - ); - Some(browser) - } - _ => { - anyhow::bail!("tab {} has inconsistent persisted content metadata", tab.public_id) - } - }; - let slot = tab_slots[&tab.public_id]; - let identity = TabResourceIdentity::new(tab.public_id.clone(), tab.content_id.clone()); - anyhow::ensure!( - indexes.tabs.insert(tab.public_id.clone(), slot).is_none(), - "duplicate tab {}", - tab.public_id - ); - indexes.tab_ids.insert(slot, tab.public_id.clone()); - indexes.content_placements.entry(tab.content_id.clone()).or_default().push(slot); - indexes.content_ids.insert(slot, tab.content_id.clone()); - contents.push(RestoredResourceContent { slot, identity, name: tab.name.clone(), browser }); - tabs_by_pane.entry(tab.pane_id.clone()).or_default().push(tab); - } - anyhow::ensure!( - browsers_by_id.is_empty(), - "resource topology contains orphan browser metadata" - ); - for tabs in tabs_by_pane.values_mut() { - tabs.sort_by_key(|tab| tab.position); - } + mux.resource_project_terminal_selected( + crate::ResourceSelectors { + terminal: Some(terminal_id.to_string()), + ..Mux::ordinary_resource_selectors() + }, + destination_selectors, + 0, + Some("projected".into()), + None, + &WorkspaceMutation::local("projection-complete-delta"), + ) + .unwrap(); - let mut panes = HashMap::new(); - for pane in &topology.panes { - let id = pane_slots[&pane.public_id]; - let pane_tabs = tabs_by_pane.get(&pane.public_id).map(Vec::as_slice).unwrap_or_default(); - let tabs = pane_tabs.iter().map(|tab| tab_slots[&tab.public_id]).collect::>(); - let active_tab = match pane.active_tab.as_ref() { - Some(active) => { - pane_tabs.iter().position(|tab| &tab.public_id == active).ok_or_else(|| { - anyhow::anyhow!("pane {} has unknown active tab {}", pane.public_id, active) - })? - } - None if pane_tabs.is_empty() => 0, - None => anyhow::bail!("pane {} has tabs but no active tab", pane.public_id), - }; - anyhow::ensure!( - panes - .insert( - id, - Pane { - id, - public_id: pane.public_id.clone(), - name: pane.name.clone(), - tabs, - active_tab, - active_at: pane.creation_ordinal, - focused_at: 0, - }, - ) - .is_none(), - "duplicate pane slot {id}" - ); - let screen = *screen_slots - .get(&pane.screen_id) - .ok_or_else(|| anyhow::anyhow!("pane {} has unknown screen", pane.public_id))?; - indexes.pane_screen.insert(id, screen); - for tab in pane_tabs { - indexes.tab_pane.insert(tab_slots[&tab.public_id], id); + let batches = mux.resource_events_after(before).unwrap().batches; + assert_eq!(batches.len(), 1); + let changes = batches[0].changes.as_array().unwrap(); + let snapshot = crate::resource_api::public_session_snapshot(&mux).unwrap(); + let terminal = snapshot["terminals"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == terminal_id.as_str()) + .unwrap(); + let terminal_delta = changes + .iter() + .find(|change| change["resource"] == "terminal" && change["id"] == terminal_id.as_str()) + .expect("projection changes the terminal's placement list"); + assert_eq!(&terminal_delta["value"], terminal); + + let destination_tabs = snapshot["tabs"] + .as_array() + .unwrap() + .iter() + .filter(|tab| tab["pane_id"] == destination_pane_id) + .collect::>(); + assert_eq!(destination_tabs.len(), 3); + for tab in destination_tabs { + let tab_id = tab["id"].as_str().unwrap(); + let delta = changes + .iter() + .find(|change| change["resource"] == "tab" && change["id"] == tab_id) + .unwrap_or_else(|| panic!("projection omitted changed tab {tab_id}")); + assert_eq!(&delta["value"], tab); } } - let mut split_slots = HashMap::::new(); - let mut screens_by_workspace = HashMap::>::new(); - for screen in &topology.screens { - let expected_panes = topology - .panes - .iter() - .filter(|pane| pane.screen_id == screen.public_id) - .map(|pane| pane.public_id.clone()) - .collect::>(); - crate::workspace_registry::validate_registry_screen_projection(screen, &expected_panes)?; - let id = screen_slots[&screen.public_id]; - let root = - restore_layout_node(&screen.layout, &pane_slots, &mut split_slots, &mut allocate)?; - let active_pane = *pane_slots.get(&screen.active_pane).ok_or_else(|| { - anyhow::anyhow!("screen {} has unknown active pane", screen.public_id) - })?; - let zoomed_pane = screen - .zoomed_pane - .as_ref() - .map(|pane| { - pane_slots.get(pane).copied().ok_or_else(|| { - anyhow::anyhow!("screen {} has unknown zoomed pane {}", screen.public_id, pane) - }) - }) - .transpose()?; - let zellij_auto_layout = screen - .auto_layout - .as_ref() - .map(|panes| { - panes - .iter() - .map(|pane| { - pane_slots.get(pane).copied().ok_or_else(|| { - anyhow::anyhow!( - "screen {} auto-layout has unknown pane {}", - screen.public_id, - pane - ) - }) - }) - .collect::>>() - }) - .transpose()?; - let (viewport_splits, viewport_base_width, layout_columns) = restore_registry_viewport( - &screen.viewport, - &pane_slots, - &mut split_slots, - &mut allocate, - )?; - indexes.screen_workspace.insert( - id, - workspaces[*workspace_index_by_public.get(&screen.workspace_id).ok_or_else(|| { - anyhow::anyhow!("screen {} has unknown workspace", screen.public_id) - })?] - .id, + #[test] + fn selector_names_preserve_empty_whitespace_and_unicode_and_report_duplicates() { + let (mut restored, machine, session, _) = selector_fixture(); + let before = restored.state.resource_revision; + let mut selectors = routed_selectors(&machine, &session); + selectors.workspace = Some("Duplicate".into()); + let duplicate = resolve_resource_selectors( + &restored.state, + ResourceSelectorContext { + machine_id: &machine, + machine_name: None, + session_id: &session, + session_name: "test", + }, + crate::ResourceTarget::Workspace, + &selectors, + ) + .unwrap_err(); + assert_eq!(duplicate.code, "selector.ambiguous"); + assert_eq!( + duplicate.details["candidates"], + serde_json::json!([restore_workspace_id(1), restore_workspace_id(2)]) ); - let restored_screen = Screen { - id, - public_id: screen.public_id.clone(), - name: screen.name.clone(), - root, - active_pane, - zoomed_pane, - zellij_auto_layout, - viewport_splits, - viewport_base_width, - layout_columns, - layout_revision: 0, - layout_undo: Default::default(), + assert_eq!(restored.state.resource_revision, before); + + for (index, name) in ["", " 日本語 "].into_iter().enumerate() { + restored.state.workspaces[index].name = name.into(); + selectors.workspace = Some(format!("name:{name}")); + let resolved = resolve_resource_selectors( + &restored.state, + ResourceSelectorContext { + machine_id: &machine, + machine_name: None, + session_id: &session, + session_name: "test", + }, + crate::ResourceTarget::Workspace, + &selectors, + ) + .unwrap(); + assert_eq!( + resolved.path.workspace, + Some(restored.state.workspaces[index].public_id.clone()) + ); + } + } + + #[test] + fn selector_rejects_incomplete_name_chain_wrong_type_stale_id_and_wrong_parent() { + let (restored, machine, session, topology) = selector_fixture(); + let context = ResourceSelectorContext { + machine_id: &machine, + machine_name: None, + session_id: &session, + session_name: "test", }; - anyhow::ensure!( - restored_screen.layout_column_projection_is_consistent(), - "screen {} has inconsistent viewport projection", - screen.public_id + + let mut incomplete = routed_selectors(&machine, &session); + incomplete.screen = Some(topology.screens[0].public_id.to_string()); + incomplete.pane = Some("one".into()); + let error = resolve_resource_selectors( + &restored.state, + context.clone(), + crate::ResourceTarget::Pane, + &incomplete, + ) + .unwrap_err(); + assert_eq!(error.code, "selector.invalid"); + assert_eq!(error.details["scope"], "pane"); + assert!( + error.details["reason"].as_str().is_some_and(|reason| reason.contains("workspace")) ); - screens_by_workspace - .entry(screen.workspace_id.clone()) - .or_default() - .push((screen.position, restored_screen)); - } - for (workspace_id, mut screens) in screens_by_workspace { - screens.sort_by_key(|(position, _)| *position); - let workspace_index = workspace_index_by_public[&workspace_id]; - workspaces[workspace_index].screens = - screens.into_iter().map(|(_, screen)| screen).collect(); - } - let mut active_screens = HashMap::new(); - for (workspace, active) in topology.active_screens { - anyhow::ensure!( - active_screens.insert(workspace.clone(), active).is_none(), - "workspace {workspace} has duplicate active-screen metadata" + + let mut wrong_type = routed_selectors(&machine, &session); + wrong_type.workspace = Some(topology.browsers[0].public_id.to_string()); + assert_eq!( + resolve_resource_selectors( + &restored.state, + context.clone(), + crate::ResourceTarget::Workspace, + &wrong_type, + ) + .unwrap_err() + .code, + "selector.invalid" ); - } - anyhow::ensure!( - active_screens.len() == workspaces.len() - && workspaces.iter().all(|workspace| active_screens.contains_key(&workspace.public_id)), - "active-screen metadata does not exactly cover the live workspaces" - ); - for workspace in &mut workspaces { - workspace.active_screen = match active_screens[&workspace.public_id].as_ref() { - Some(active) => { - workspace.screens.iter().position(|screen| &screen.public_id == active).ok_or_else( - || { - anyhow::anyhow!( - "workspace {} has unknown active screen {}", - workspace.public_id, - active - ) - }, - )? - } - None if workspace.screens.is_empty() => 0, - None => { - anyhow::bail!("workspace {} has screens but no active screen", workspace.public_id) - } - }; - } - let active_workspace = match topology.active_workspace.as_ref() { - Some(active) => workspaces - .iter() - .position(|workspace| &workspace.public_id == active) - .ok_or_else(|| anyhow::anyhow!("unknown active workspace {active}"))?, - None if workspaces.is_empty() => 0, - None => anyhow::bail!("session has workspaces but no active workspace"), - }; - for (public_id, slot) in split_slots { - indexes.splits.insert(public_id.clone(), slot); - indexes.split_ids.insert(slot, public_id); - } - let workspace_index_by_id = - workspaces.iter().enumerate().map(|(index, workspace)| (workspace.id, index)).collect(); - let workspace_id_by_key = - workspaces.iter().map(|workspace| (workspace.key.clone(), workspace.id)).collect(); - Ok(RestoredResourceState { - state: State { - workspaces, - workspace_index_by_id, - workspace_id_by_key, - workspace_revision, - pane_revision: panes.len() as u64, - resource_revision, - focus_sequence: 0, - active_workspace, - panes, - surfaces: HashMap::new(), - terminal_catalog: HashMap::new(), - terminal_catalog_by_runtime: HashMap::new(), - split_screens: HashMap::new(), - resource_indexes: indexes, - }, - next_id, - contents, - }) -} + let mut stale = routed_selectors(&machine, &session); + stale.workspace = Some(WorkspacePublicId::random().unwrap().to_string()); + assert_eq!( + resolve_resource_selectors( + &restored.state, + context.clone(), + crate::ResourceTarget::Workspace, + &stale, + ) + .unwrap_err() + .code, + "selector.not_found" + ); -fn restore_layout_node( - node: &RegistryLayoutNode, - panes: &HashMap, - splits: &mut HashMap, - allocate: &mut impl FnMut() -> anyhow::Result, -) -> anyhow::Result { - Ok(match node { - RegistryLayoutNode::Leaf { pane } => Node::Leaf( - *panes.get(pane).ok_or_else(|| anyhow::anyhow!("layout has unknown pane {pane}"))?, - ), - RegistryLayoutNode::Split { split, direction, ratio, first, second } => { - anyhow::ensure!(!splits.contains_key(split), "split {split} appears more than once"); - let id = allocate()?; - splits.insert(split.clone(), id); - let dir = match direction.as_str() { - "right" => SplitDir::Right, - "down" => SplitDir::Down, - _ => anyhow::bail!("split {split} has invalid direction {direction:?}"), - }; - Node::Split { - id, - dir, - ratio: *ratio, - a: Box::new(restore_layout_node(first, panes, splits, allocate)?), - b: Box::new(restore_layout_node(second, panes, splits, allocate)?), - } - } - RegistryLayoutNode::Stack { panes: members, expanded } => { - let members = members - .iter() - .map(|pane| { - panes - .get(pane) - .copied() - .ok_or_else(|| anyhow::anyhow!("stack has unknown pane {pane}")) - }) - .collect::>>()?; - let expanded = *panes - .get(expanded) - .ok_or_else(|| anyhow::anyhow!("stack has unknown expanded pane {expanded}"))?; - Node::stack_with_expanded(members, expanded) - .ok_or_else(|| anyhow::anyhow!("stored stack is empty or has invalid selection"))? + let mut wrong_parent = routed_selectors(&machine, &session); + wrong_parent.workspace = Some(restore_workspace_id(2).to_string()); + wrong_parent.browser = Some(topology.browsers[0].public_id.to_string()); + let error = resolve_resource_selectors( + &restored.state, + context, + crate::ResourceTarget::Browser, + &wrong_parent, + ) + .unwrap_err(); + assert_eq!(error.code, "selector.wrong_parent"); + assert!(error.details["expected_parent"].as_str().unwrap().starts_with("ws_")); + assert!(error.details["actual_parent"].as_str().unwrap().starts_with("ws_")); + let encoded = serde_json::to_string(&error).unwrap(); + for private in ["workspace_key", "surface", "numeric_id", "short_id", "\"slot\""] { + assert!(!encoded.contains(private)); } - }) -} - -fn restore_registry_viewport( - viewport: &RegistryViewport, - panes: &HashMap, - splits: &mut HashMap, - allocate: &mut impl FnMut() -> anyhow::Result, -) -> anyhow::Result { - if viewport.columns.is_empty() { - return Ok((Default::default(), None, Vec::new())); } - let mut columns = Vec::with_capacity(viewport.columns.len()); - for (index, column) in viewport.columns.iter().enumerate() { - let id = match splits.get(&column.id).copied() { - Some(id) => id, - None if index == 0 => { - let id = allocate()?; - splits.insert(column.id.clone(), id); - id + + #[test] + fn selector_rename_and_concurrent_rename_share_one_locked_snapshot() { + let mux = test_mux(); + let first = mux + .resource_create_empty_workspace( + Some("target".into()), + None, + Some(0), + &WorkspaceMutation::new("selector-race-first", "test").unwrap(), + ) + .unwrap(); + let second = mux + .resource_create_empty_workspace( + Some("other".into()), + None, + Some(1), + &WorkspaceMutation::new("selector-race-second", "test").unwrap(), + ) + .unwrap(); + let first_id = + WorkspacePublicId::parse(first.result["workspace"].as_str().unwrap()).unwrap(); + let second_id = + WorkspacePublicId::parse(second.result["workspace"].as_str().unwrap()).unwrap(); + let (machine, session) = { + let registry = mux.workspace_registry.lock().unwrap(); + (registry.machine_id().clone(), registry.session_id().clone()) + }; + let mut selectors = routed_selectors(&machine, &session); + selectors.workspace = Some("target".into()); + + let (resolved_tx, resolved_rx) = std::sync::mpsc::sync_channel(1); + let overlap = Arc::new(std::sync::Barrier::new(2)); + *mux.resource_rename_after_selector_resolution.lock().unwrap() = Some(Arc::new({ + let overlap = overlap.clone(); + move |resolved| { + resolved_tx.send(resolved.clone()).unwrap(); + overlap.wait(); } - None => anyhow::bail!("viewport references unknown boundary split {}", column.id), + })); + let selected = { + let mux = mux.clone(); + std::thread::spawn(move || { + mux.resource_rename_workspace_selected( + selectors, + "renamed".into(), + None, + Some(2), + &WorkspaceMutation::new("selector-race-selected", "test").unwrap(), + ) + }) }; - let root = restore_layout_node_from_known_splits(&column.layout, panes, splits)?; - let zellij_auto_layout = column - .auto_layout - .as_ref() - .map(|members| { - members - .iter() - .map(|pane| { - panes.get(pane).copied().ok_or_else(|| { - anyhow::anyhow!("viewport auto-layout has unknown pane {pane}") - }) - }) - .collect::>>() + assert_eq!(resolved_rx.recv().unwrap(), first_id); + let concurrent = { + let mux = mux.clone(); + let second_id = second_id.clone(); + std::thread::spawn(move || { + overlap.wait(); + mux.resource_rename_workspace( + &second_id, + "target".into(), + None, + None, + &WorkspaceMutation::new("selector-race-direct", "test").unwrap(), + ) }) - .transpose()?; - columns.push(LayoutColumn { id, width: column.width, root, zellij_auto_layout }); + }; + selected.join().unwrap().unwrap(); + concurrent.join().unwrap().unwrap(); + *mux.resource_rename_after_selector_resolution.lock().unwrap() = None; + + mux.with_state(|state| { + let first = state.resource_indexes.workspaces[&first_id]; + let second = state.resource_indexes.workspaces[&second_id]; + assert_eq!(state.workspaces[state.workspace_index(first).unwrap()].name, "renamed"); + assert_eq!(state.workspaces[state.workspace_index(second).unwrap()].name, "target"); + }); } - let viewport_splits = columns.iter().skip(1).map(|column| (column.id, column.width)).collect(); - Ok((viewport_splits, viewport.base_width, columns)) -} -fn restore_layout_node_from_known_splits( - node: &RegistryLayoutNode, - panes: &HashMap, - splits: &HashMap, -) -> anyhow::Result { - Ok(match node { - RegistryLayoutNode::Leaf { pane } => Node::Leaf( - *panes.get(pane).ok_or_else(|| anyhow::anyhow!("layout has unknown pane {pane}"))?, - ), - RegistryLayoutNode::Split { split, direction, ratio, first, second } => { - let id = *splits - .get(split) - .ok_or_else(|| anyhow::anyhow!("layout has unknown split {split}"))?; - let dir = match direction.as_str() { - "right" => SplitDir::Right, - "down" => SplitDir::Down, - _ => anyhow::bail!("split {split} has invalid direction {direction:?}"), - }; - Node::Split { - id, - dir, - ratio: *ratio, - a: Box::new(restore_layout_node_from_known_splits(first, panes, splits)?), - b: Box::new(restore_layout_node_from_known_splits(second, panes, splits)?), - } - } - RegistryLayoutNode::Stack { panes: members, expanded } => { - let members = members - .iter() - .map(|pane| { - panes - .get(pane) - .copied() - .ok_or_else(|| anyhow::anyhow!("stack has unknown pane {pane}")) - }) - .collect::>>()?; - let expanded = *panes - .get(expanded) - .ok_or_else(|| anyhow::anyhow!("stack has unknown expanded pane {expanded}"))?; - Node::stack_with_expanded(members, expanded) - .ok_or_else(|| anyhow::anyhow!("stored stack is empty or has invalid selection"))? + #[test] + fn resource_startup_restores_nested_columns_selections_and_empty_workspace() { + let (snapshot, topology) = resource_restore_fixture(); + let expected_tab = topology.tabs[5].public_id.clone(); + let expected_browser = topology.tabs[5].content_id.clone(); + let expected_base_column = topology.screens[0].viewport.columns[0].id.clone(); + let mut restored = restore_resource_state(snapshot, topology).unwrap(); + Mux::rebuild_split_screen_index(&mut restored.state); + + let state = &restored.state; + assert_eq!(state.workspaces.len(), 2); + assert_eq!(state.active_workspace, 1); + assert!(state.workspaces[1].screens.is_empty()); + assert_eq!(state.workspaces[0].active_screen, 1); + let columns = &state.workspaces[0].screens[0]; + assert_eq!(columns.layout_columns.len(), 2); + assert_eq!(columns.viewport_base_width, Some(0.8)); + assert_eq!(columns.zoomed_pane, Some(columns.active_pane)); + assert!(matches!( + &columns.layout_columns[0].root, + Node::Split { a, .. } + if matches!(a.as_ref(), Node::Stack { expanded, .. } + if *expanded == state.resource_indexes.panes[&restore_pane_id(2)]) + )); + assert!(columns.layout_column_projection_is_consistent()); + assert!(state.resource_indexes.splits.contains_key(&expected_base_column)); + let selected_pane = state.resource_indexes.panes[&restore_pane_id(5)]; + assert_eq!( + state.panes[&selected_pane].tabs[state.panes[&selected_pane].active_tab], + state.resource_indexes.tabs[&expected_tab] + ); + assert_eq!( + state.resource_indexes.content_placements[&expected_browser][0], + state.resource_indexes.tabs[&expected_tab] + ); + assert_eq!(state.surfaces.len(), 0); + assert_eq!(restored.contents.len(), 6); + assert!(restored.next_id > 100); + } + + #[test] + fn restored_terminal_runtime_materializes_all_durable_views_and_survives_zero_views() { + let (snapshot, mut topology) = resource_restore_fixture(); + let terminal_id = restore_terminal_id(50); + let host_id = "00000000000040008000000000000050"; + let replaced_browsers = + topology.tabs[..2].iter().map(|tab| tab.content_id.clone()).collect::>(); + topology.browsers.retain(|browser| { + !replaced_browsers.contains(&ContentPublicId::Browser(browser.public_id.clone())) + }); + for tab in &mut topology.tabs[..2] { + tab.content_id = ContentPublicId::Terminal(terminal_id.clone()); + tab.browser_url = None; + tab.terminal_id = Some(host_id.into()); } - }) -} + let mut restored = restore_resource_state(snapshot, topology.clone()).unwrap(); + let content_id = ContentPublicId::Terminal(terminal_id.clone()); + let placements = restored.state.placements_of_content(&content_id).to_vec(); + assert_eq!(placements.len(), 2); + let first_tab = topology.tabs[0].public_id.clone(); + let source = Surface::spawn_for_test_with_resource_identity( + placements[0], + SurfaceOptions::default(), + Weak::new(), + Some(TabResourceIdentity::new(first_tab, content_id.clone())), + ) + .unwrap(); -/// Every surface in a screen (all panes, all tabs). -fn screen_tabs(state: &State, screen: &Screen) -> Vec { - let mut pane_ids = Vec::new(); - screen.root.pane_ids(&mut pane_ids); - pane_ids - .iter() - .filter_map(|id| state.panes.get(id)) - .flat_map(|pane| pane.tabs.iter().copied()) - .collect() -} + let mux = test_mux(); + insert_restored_terminal_runtime_checked(&mux, &mut restored.state, source.clone()) + .unwrap(); + assert_eq!(restored.state.terminal_catalog.len(), 1); + for placement in &placements { + assert!( + restored.state.surfaces[placement].shares_terminal_runtime(&source), + "every durable tab must project the one restored terminal runtime" + ); + } -fn stamp_pane_focus(mux: &Mux, state: &mut State, pane: PaneId) { - let focused_at = state.next_focus_sequence(); - let active_at = mux.next_active_at(); - if let Some(pane) = state.panes.get_mut(&pane) { - pane.active_at = active_at; - pane.focused_at = focused_at; + for placement in placements { + let _ = remove_surface(&mux, &mut restored.state, placement); + } + assert!(restored.state.surfaces.is_empty()); + assert!(restored.state.placements_of_content(&content_id).is_empty()); + assert!( + restored + .state + .terminal_catalog + .get(&terminal_id) + .is_some_and(|catalogued| catalogued.shares_terminal_runtime(&source)), + "removing the final view must retain the restored runtime owner" + ); } -} -fn stamp_changed_active_pane(mux: &Mux, state: &mut State, previous: Option) { - let current = state.active_pane(); - if current != previous - && let Some(pane) = current - { - stamp_pane_focus(mux, state, pane); - } -} + #[test] + fn resource_startup_rejects_corrupt_persisted_selectors() { + let (snapshot, mut topology) = resource_restore_fixture(); + topology.active_screens[0].1 = Some(restore_screen_id(999)); + assert!( + restore_resource_state(snapshot.clone(), topology) + .err() + .unwrap() + .to_string() + .contains("unknown active screen") + ); -fn most_recent_pane(state: &State, panes: &[PaneId]) -> Option { - panes - .iter() - .filter_map(|id| state.panes.get(id).map(|pane| (*id, pane.active_at))) - .max_by_key(|(_, active_at)| *active_at) - .map(|(id, _)| id) -} + let (_, mut topology) = resource_restore_fixture(); + topology.panes[0].active_tab = Some(restore_tab_id(999)); + assert!( + restore_resource_state(snapshot, topology) + .err() + .unwrap() + .to_string() + .contains("unknown active tab") + ); + } -fn clamp_split_ratio(ratio: f32) -> f32 { - ratio.clamp(0.05, 0.95) -} + #[test] + fn resource_startup_requires_exact_browser_restart_metadata_coverage() { + let (snapshot, mut topology) = resource_restore_fixture(); + topology.browsers.pop(); + assert!( + restore_resource_state(snapshot.clone(), topology) + .err() + .unwrap() + .to_string() + .contains("has no restart metadata") + ); -fn append_to_auto_layout( - root: &mut Node, - auto_layout: &mut Option>, - pane: PaneId, - mut next_id: impl FnMut() -> SplitId, -) { - let mut panes = auto_layout.clone().unwrap_or_else(|| { - let mut panes = Vec::new(); - root.pane_ids(&mut panes); - panes.sort_unstable(); - panes - }); - let current_panes = root.pane_ids_vec().into_iter().collect::>(); - panes.retain(|pane| current_panes.contains(pane)); - panes.push(pane); - *root = crate::layout::zellij_default_pane_layout_with_ids(&panes, &mut next_id) - .expect("new pane layout always has at least one pane"); - *auto_layout = Some(panes); -} + let (_, mut topology) = resource_restore_fixture(); + let mut orphan = topology.browsers[0].clone(); + orphan.public_id = restore_browser_id(999); + topology.browsers.push(orphan); + assert!( + restore_resource_state(snapshot.clone(), topology) + .err() + .unwrap() + .to_string() + .contains("orphan browser metadata") + ); -fn remove_pane_from_screen_layout(mux: &Mux, screen: &mut Screen, pane: PaneId) -> bool { - screen.invalidate_layout_undo(); - if screen.layout_columns_active() { - let Some(index) = - screen.layout_columns.iter().position(|column| column.root.contains(pane)) - else { - return true; - }; - let column = &mut screen.layout_columns[index]; - let root = std::mem::replace(&mut column.root, Node::Leaf(0)); - let stack_expanded = root.stack_expanded_pane(); - match root.remove_leaf(pane) { - Some(mut root) => { - if let Some(panes) = column.zellij_auto_layout.as_mut() { - panes.retain(|candidate| *candidate != pane); - if let Some(layout) = - crate::layout::zellij_default_pane_layout_with_ids(panes, &mut || { - mux.next_id() - }) - { - root = layout; - if let Some(expanded) = stack_expanded { - root.expand_stack_pane(expanded); - } - } else { - column.zellij_auto_layout = None; - } - } - column.root = root; - } - None => { - screen.layout_columns.remove(index); - } - } - if screen.layout_columns.is_empty() { - return false; - } - screen.collapse_single_layout_column(); - return true; + let (_, mut topology) = resource_restore_fixture(); + topology.browsers.push(topology.browsers[0].clone()); + assert!( + restore_resource_state(snapshot, topology) + .err() + .unwrap() + .to_string() + .contains("duplicate browser metadata") + ); } - let root = std::mem::replace(&mut screen.root, Node::Leaf(0)); - let stack_expanded = root.stack_expanded_pane(); - let Some(mut root) = root.remove_leaf(pane) else { - return false; - }; - if let Some(panes) = screen.zellij_auto_layout.as_mut() { - panes.retain(|candidate| *candidate != pane); - if let Some(layout) = - crate::layout::zellij_default_pane_layout_with_ids(panes, &mut || mux.next_id()) - { - root = layout; - if let Some(expanded) = stack_expanded { - root.expand_stack_pane(expanded); - } - } else { - screen.zellij_auto_layout = None; - } - } - screen.root = root; - true -} + #[test] + fn resource_startup_rejects_missing_required_active_selectors() { + let (snapshot, mut topology) = resource_restore_fixture(); + topology.panes[0].active_tab = None; + assert_eq!( + restore_resource_state(snapshot.clone(), topology).err().unwrap().to_string(), + format!("pane {} has tabs but no active tab", restore_pane_id(1)) + ); -fn unique_screen_ids(ids: impl IntoIterator) -> Vec { - let mut unique = Vec::new(); - for id in ids { - if !unique.contains(&id) { - unique.push(id); - } - } - unique -} + let (_, mut topology) = resource_restore_fixture(); + topology.active_screens[0].1 = None; + assert_eq!( + restore_resource_state(snapshot.clone(), topology).err().unwrap().to_string(), + format!("workspace {} has screens but no active screen", restore_workspace_id(1)) + ); -#[derive(Clone, Copy, PartialEq, Eq)] -struct ActiveTreeSelection { - workspace: Option, - screen: Option, - pane: Option, - surface: Option, -} + let (_, mut topology) = resource_restore_fixture(); + topology.active_workspace = None; + assert_eq!( + restore_resource_state(snapshot.clone(), topology).err().unwrap().to_string(), + "session has workspaces but no active workspace" + ); -fn active_tree_selection(state: &State) -> ActiveTreeSelection { - let workspace = state.workspaces.get(state.active_workspace); - let screen = workspace.and_then(|workspace| workspace.screens.get(workspace.active_screen)); - let pane = screen.and_then(|screen| state.panes.get(&screen.active_pane)); - ActiveTreeSelection { - workspace: workspace.map(|workspace| workspace.id), - screen: screen.map(|screen| screen.id), - pane: screen.map(|screen| screen.active_pane), - surface: pane.and_then(|pane| pane.tabs.get(pane.active_tab)).copied(), + let (_, mut topology) = resource_restore_fixture(); + topology.active_screens.pop(); + assert_eq!( + restore_resource_state(snapshot, topology).err().unwrap().to_string(), + "active-screen metadata does not exactly cover the live workspaces" + ); } -} -fn surface_screen_id(state: &State, surface: SurfaceId) -> Option { - let pane = state.pane_of(surface)?; - let (wi, si) = state.screen_of(pane)?; - Some(state.workspaces[wi].screens[si].id) -} + #[test] + fn resource_startup_accepts_none_only_for_empty_containers() { + let (snapshot, mut topology) = resource_restore_fixture(); + let empty_pane = topology.panes[0].public_id.clone(); + topology.tabs.retain(|tab| tab.pane_id != empty_pane); + let retained_browsers = topology + .tabs + .iter() + .filter_map(|tab| match &tab.content_id { + ContentPublicId::Browser(browser) => Some(browser.clone()), + ContentPublicId::Terminal(_) => None, + }) + .collect::>(); + topology.browsers.retain(|browser| retained_browsers.contains(&browser.public_id)); + topology.panes[0].active_tab = None; + let restored = restore_resource_state(snapshot, topology).unwrap(); + assert!( + restored.state.panes[&restored.state.resource_indexes.panes[&empty_pane]] + .tabs + .is_empty() + ); -fn resolve_workspace_index( - state: &State, - id: Option, - key: Option<&str>, -) -> anyhow::Result { - if id.is_none() && key.is_none() { - anyhow::bail!("workspace or key is required"); - } - let by_id = id.and_then(|id| state.workspaces.iter().position(|workspace| workspace.id == id)); - let by_key = - key.and_then(|key| state.workspaces.iter().position(|workspace| workspace.key == key)); - match (id, key, by_id, by_key) { - (Some(id), _, None, _) => anyhow::bail!("unknown workspace {id}"), - (_, Some(key), _, None) => anyhow::bail!("unknown workspace key {key}"), - (Some(_), Some(_), Some(left), Some(right)) if left != right => { - anyhow::bail!("workspace and key identify different workspaces") - } - (_, _, Some(index), _) | (_, _, _, Some(index)) => Ok(index), - _ => anyhow::bail!("unknown workspace"), + let (mut snapshot, mut topology) = resource_restore_fixture(); + snapshot.workspaces.clear(); + topology.active_workspace = None; + topology.active_screens.clear(); + topology.screens.clear(); + topology.panes.clear(); + topology.tabs.clear(); + topology.browsers.clear(); + assert!(restore_resource_state(snapshot, topology).unwrap().state.workspaces.is_empty()); } -} -fn workspace_mutation_result(commit: &RegistryCommit) -> anyhow::Result { - let workspace = commit.result["workspace"].as_u64(); - let key = commit.result["key"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("stored workspace mutation result is missing key"))? - .to_string(); - let index = commit.result["index"] - .as_u64() - .map(usize::try_from) - .transpose() - .context("stored workspace mutation index is invalid")?; - let changed = commit.result["changed"].as_bool().unwrap_or(true); - Ok(WorkspaceMutationResult { - workspace, - key, - index, - revision: commit.revision, - replayed: commit.replayed, - changed, - }) -} + #[test] + fn resource_state_patch_commits_before_infallible_projection_and_replays_once() { + let mux = test_mux(); + let mutation = WorkspaceMutation::new("create-once", "test-client").unwrap(); + let first = mux + .resource_create_empty_workspace(Some("API".into()), None, Some(0), &mutation) + .unwrap(); + assert_eq!(first.revision, 1); + assert!(!first.replayed); + let public_id = + WorkspacePublicId::parse(first.result["workspace"].as_str().unwrap()).unwrap(); + mux.with_state(|state| { + assert_eq!(state.resource_revision, 1); + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].public_id, public_id); + assert_eq!(state.workspaces[0].name, "API"); + }); + let durable = mux.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); + assert_eq!(durable.revision, 1); + assert_eq!(durable.active_workspace, Some(public_id)); -fn close_surface_delta( - state: &State, - notifications: &HashMap, - surface: SurfaceId, -) -> Option { - let pane_id = state.pane_of(surface)?; - let pane = state.panes.get(&pane_id)?; - let tab_index = pane.tabs.iter().position(|candidate| *candidate == surface)?; - let (wi, si) = state.screen_of(pane_id)?; - let workspace = &state.workspaces[wi]; - let screen = &workspace.screens[si]; - if pane.tabs.len() > 1 { - let entity = crate::server::tree_entity_json( - state, - notifications, - TreeDeltaKind::TabClosed, - surface, - )?; - return Some(TreeDelta { - kind: TreeDeltaKind::TabClosed, - workspace: workspace.id, - screen: Some(screen.id), - pane: Some(pane_id), - surface: Some(surface), - index: Some(tab_index), - entity, - workspace_revision: None, + let replay = mux + .resource_create_empty_workspace(Some("API".into()), None, Some(0), &mutation) + .unwrap(); + assert!(replay.replayed); + assert_eq!(replay.revision, 1); + assert_eq!(replay.result, first.result); + mux.with_state(|state| { + assert_eq!(state.resource_revision, 1); + assert_eq!(state.workspaces.len(), 1); }); } - close_pane_delta(state, notifications, pane_id) -} -fn close_pane_delta( - state: &State, - notifications: &HashMap, - pane: PaneId, -) -> Option { - let (wi, si) = state.screen_of(pane)?; - let workspace = &state.workspaces[wi]; - let screen = &workspace.screens[si]; - let mut panes = Vec::new(); - screen.root.pane_ids(&mut panes); - if panes.len() > 1 { - let entity = - crate::server::tree_entity_json(state, notifications, TreeDeltaKind::PaneClosed, pane)?; - return Some(TreeDelta { - kind: TreeDeltaKind::PaneClosed, - workspace: workspace.id, - screen: Some(screen.id), - pane: Some(pane), - surface: None, - index: Some(panes.iter().position(|candidate| *candidate == pane)?), - entity, - workspace_revision: None, + #[test] + fn resource_state_patch_failure_leaves_memory_and_database_unchanged() { + let mux = test_mux(); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); + let error = mux + .resource_create_empty_workspace( + Some("Never visible".into()), + None, + Some(0), + &WorkspaceMutation::new("fail-create", "test-client").unwrap(), + ) + .unwrap_err(); + assert!(error.to_string().contains("forced resource patch failure")); + mux.with_state(|state| { + assert!(state.workspaces.is_empty()); + assert_eq!(state.resource_revision, 0); }); + let registry = mux.workspace_registry.lock().unwrap(); + assert_eq!(registry.resource_topology_snapshot().unwrap().revision, 0); + assert!(registry.snapshot().unwrap().workspaces.is_empty()); + registry.set_resource_patch_failure(false).unwrap(); } - close_screen_delta(state, notifications, screen.id) -} - -fn close_screen_delta( - state: &State, - notifications: &HashMap, - screen: ScreenId, -) -> Option { - let (wi, si) = state.workspaces.iter().enumerate().find_map(|(wi, workspace)| { - workspace.screens.iter().position(|candidate| candidate.id == screen).map(|si| (wi, si)) - })?; - let workspace = &state.workspaces[wi]; - let entity = - crate::server::tree_entity_json(state, notifications, TreeDeltaKind::ScreenClosed, screen)?; - Some(TreeDelta { - kind: TreeDeltaKind::ScreenClosed, - workspace: workspace.id, - screen: Some(screen), - pane: None, - surface: None, - index: Some(si), - entity, - workspace_revision: None, - }) -} - -fn close_workspace_delta( - state: &State, - notifications: &HashMap, - workspace: WorkspaceId, -) -> Option { - let index = state.workspace_index(workspace)?; - let entity = crate::server::tree_entity_json( - state, - notifications, - TreeDeltaKind::WorkspaceClosed, - workspace, - )?; - Some(TreeDelta { - kind: TreeDeltaKind::WorkspaceClosed, - workspace, - screen: None, - pane: None, - surface: None, - index: Some(index), - entity, - workspace_revision: None, - }) -} -fn update_layout_undo_token_part(hasher: &mut Sha256, value: &[u8]) { - hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes()); - hasher.update(value); -} + fn begin_test_resource_effect(mux: &Mux, idempotency_key: &str, operation: &str) -> Value { + let fingerprint = serde_json::json!({ + "operation": operation, + "fixture": idempotency_key, + }); + let expected_revision = mux.with_state(|state| state.resource_revision); + assert!(matches!( + mux.prepare_resource_effect( + idempotency_key, + operation, + &fingerprint, + &serde_json::json!({}), + None, + Some(expected_revision), + ) + .unwrap(), + ResourceEffectPreparation::Execute { resumed: false, .. } + )); + mux.mark_resource_effect_executing(idempotency_key, operation, &fingerprint).unwrap(); + fingerprint + } -fn layout_undo_confirmation_details( - state: &State, - registry: &WorkspaceRegistry, - workspace_index: usize, - screen_index: usize, -) -> anyhow::Result { - let screen = state - .workspaces - .get(workspace_index) - .and_then(|workspace| workspace.screens.get(screen_index)) - .context("layout undo screen disappeared")?; - let entry = screen.layout_undo.back().ok_or(LayoutUndoError::Unavailable)?; - if entry.after_revision != screen.layout_revision { - return Err(LayoutUndoError::Stale( - "layout changed since the last undoable action".to_string(), + #[test] + fn receipt_only_effect_commit_wakes_journal_subscribers() { + let mux = test_mux(); + let fingerprint = begin_test_resource_effect(&mux, "receipt-only-effect", "terminal.input"); + let before = mux.journal_event_epoch(); + mux.commit_resource_effect( + "receipt-only-effect", + "terminal.input", + &fingerprint, + &ResourceEffectOutcome::Success(serde_json::json!({})), + None, ) - .into()); + .unwrap(); + assert_eq!(mux.journal_event_epoch(), before + 1); } - anyhow::ensure!(!entry.created_panes.is_empty(), "layout undo does not require confirmation"); - let mut hasher = Sha256::new(); - update_layout_undo_token_part(&mut hasher, b"cmux.layout-undo.confirmation.v1"); - update_layout_undo_token_part(&mut hasher, registry.generation().as_bytes()); - update_layout_undo_token_part(&mut hasher, screen.public_id.to_string().as_bytes()); - update_layout_undo_token_part(&mut hasher, &screen.layout_revision.to_be_bytes()); - hasher.update(u64::try_from(entry.created_panes.len()).unwrap_or(u64::MAX).to_be_bytes()); + #[test] + fn projected_effect_failure_rolls_back_revision_event_and_topology() { + let mux = test_mux(); + let surface = + mux.new_browser_tab("about:blank#rollback".into(), None, Some((80, 24))).unwrap(); + let before_revision = mux.with_state(|state| state.resource_revision); + let fingerprint = + begin_test_resource_effect(&mux, "effect-projection-rollback", "test.project"); + let before_epoch = mux.resource_event_epoch(); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); - let mut closes_panes = Vec::with_capacity(entry.created_panes.len()); - for created in &entry.created_panes { - let pane_id = state - .resource_indexes - .pane_ids - .get(created) - .with_context(|| format!("pane {created} has no public identity"))?; - let pane = state - .panes - .get(created) - .with_context(|| format!("created pane {created} disappeared before undo preview"))?; - closes_panes.push(pane_id.clone()); - update_layout_undo_token_part(&mut hasher, pane_id.to_string().as_bytes()); - hasher.update(u64::try_from(pane.tabs.len()).unwrap_or(u64::MAX).to_be_bytes()); - for surface in &pane.tabs { - let tab_id = state - .resource_indexes - .tab_ids - .get(surface) - .cloned() - .or_else(|| { - state - .surfaces - .get(surface) - .and_then(|surface| surface.resource_identity()) - .map(|identity| identity.tab_id.clone()) - }) - .with_context(|| format!("tab {surface} has no public identity"))?; - update_layout_undo_token_part(&mut hasher, tab_id.to_string().as_bytes()); - } + let error = mux + .commit_full_resource_effect_projection( + "effect-projection-rollback", + "test.project", + &fingerprint, + serde_json::json!({"projected":true}), + ) + .unwrap_err(); + + assert!(error.to_string().contains("forced resource patch failure")); + assert_eq!(mux.resource_event_epoch(), before_epoch); + mux.with_state(|state| assert_eq!(state.resource_revision, before_revision)); + let registry = mux.workspace_registry.lock().unwrap(); + assert_eq!(registry.resource_topology_snapshot().unwrap().revision, before_revision); + assert!(registry.resource_events_after(before_revision).unwrap().batches.is_empty()); + registry.set_resource_patch_failure(false).unwrap(); + surface.kill(); } - let confirmation_token = - hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::(); - let revision = registry.resource_topology_snapshot()?.revision; - Ok(serde_json::json!({ - "revision":revision.to_string(), - "confirmation_token":confirmation_token, - "closes_panes":closes_panes, - })) -} -/// Advance the confirmation fence when a tab membership mutation touches a -/// pane that the latest undo would close. This runs in the same state-lock -/// critical section as the tab mutation, so a confirmed undo observes either -/// the old membership and revision or the new membership and revision. -fn fence_layout_undo_for_tab_membership(state: &mut State, panes: &[PaneId]) { - let screens = panes - .iter() - .filter_map(|pane| state.screen_of(*pane)) - .map(|(workspace, screen)| state.workspaces[workspace].screens[screen].id) - .collect::>(); - for screen_id in screens { - let Some(screen) = state - .workspaces - .iter_mut() - .flat_map(|workspace| workspace.screens.iter_mut()) - .find(|screen| screen.id == screen_id) - else { - continue; + #[test] + fn projected_effect_preserves_restored_terminal_before_surface_adoption() { + let mux = test_mux(); + let surface = mux.new_workspace(Some("Restored".into()), Some((80, 24))).unwrap(); + let identity = surface.resource_identity().unwrap().clone(); + let ContentPublicId::Terminal(terminal_id) = identity.content_id.clone() else { + panic!("workspace fixture must create a terminal"); }; - let affects_created_pane = screen.layout_undo.back().is_some_and(|entry| { - entry.after_revision == screen.layout_revision - && entry.created_panes.iter().any(|created| panes.contains(created)) - }); - if !affects_created_pane { - continue; - } - let revision = screen.layout_revision.saturating_add(1); - screen.layout_revision = revision; - let entry = screen.layout_undo.back_mut().expect("validated undo entry remains present"); - entry.after_revision = revision; - entry.coalesce = None; + let host = mux.resource_terminal_host_identity(&surface).unwrap(); + let removed = mux.state.lock().unwrap().surfaces.remove(&surface.id); + assert!(removed.is_some(), "surface must exist before simulating delayed adoption"); + + let projection = mux + .resource_effect_projection() + .expect("a restored terminal may be projected before its surface is adopted"); + + assert!(projection.patch.changes.iter().any(|change| matches!( + change, + ResourceChange::UpsertTab(tab) + if tab.public_id == identity.tab_id + && tab.content_id == ContentPublicId::Terminal(terminal_id.clone()) + ))); + assert!(projection.patch.changes.iter().any(|change| matches!( + change, + ResourceChange::UpsertTerminal { public_id, terminal } + if public_id == &terminal_id && terminal.terminal_id == host.terminal_id + ))); + assert!(!projection.patch.changes.iter().any(|change| matches!( + change, + ResourceChange::TombstoneTab { tab_id, .. } if tab_id == &identity.tab_id + ))); + assert!(!projection.patch.changes.iter().any(|change| matches!( + change, + ResourceChange::TombstoneTerminal { public_id, .. } if public_id == &terminal_id + ))); + surface.kill(); } -} -/// Remove one surface from the state: detach it from its -/// pane, and collapse emptied panes/screens. Empty workspaces remain as -/// canonical registry entries. Returns the removed surface and whether -/// split ownership or positional indexes changed. Runs under the state lock. -fn remove_surface(mux: &Mux, state: &mut State, target: SurfaceId) -> (Option>, bool) { - let previous_active = state.active_pane(); - let removed = state.surfaces.remove(&target); - if let Some(tab_id) = state.resource_indexes.tab_ids.remove(&target) { - state.resource_indexes.tabs.remove(&tab_id); - } - if let Some(content_id) = state.resource_indexes.content_ids.remove(&target) { - let remove_content = if let Some(placements) = - state.resource_indexes.content_placements.get_mut(&content_id) - { - placements.retain(|placement| *placement != target); - placements.is_empty() - } else { - false + #[test] + fn projected_effect_holds_writer_fence_through_commit_and_publishes_once() { + use std::sync::mpsc; + + let mux = test_mux(); + let surface = mux.new_browser_tab("about:blank#race".into(), None, Some((80, 24))).unwrap(); + let original_name = mux.with_state(|state| state.workspaces[0].name.clone()); + let before_revision = mux.with_state(|state| state.resource_revision); + let before_epoch = mux.resource_event_epoch(); + let fingerprint = + begin_test_resource_effect(&mux, "effect-projection-race", "test.project"); + let projection_ready = Arc::new(std::sync::Barrier::new(2)); + let allow_commit = Arc::new(std::sync::Barrier::new(2)); + *mux.resource_projection_before_commit.lock().unwrap() = Some(Arc::new({ + let projection_ready = projection_ready.clone(); + let allow_commit = allow_commit.clone(); + move || { + projection_ready.wait(); + allow_commit.wait(); + } + })); + + let commit_thread = { + let mux = mux.clone(); + std::thread::spawn(move || { + mux.commit_full_resource_effect_projection( + "effect-projection-race", + "test.project", + &fingerprint, + serde_json::json!({"projected":true}), + ) + .unwrap() + }) }; - if remove_content { - state.resource_indexes.content_placements.remove(&content_id); - } - } - state.resource_indexes.tab_pane.remove(&target); - let Some(pane_id) = state.pane_of(target) else { - return (removed, false); - }; - let pane = state.panes.get_mut(&pane_id).expect("pane_of returned live id"); - let idx = pane.tabs.iter().position(|id| *id == target).expect("tab in pane"); - pane.tabs.remove(idx); - if !pane.tabs.is_empty() { - if pane.active_tab >= idx && pane.active_tab > 0 { - pane.active_tab -= 1; - } - fence_layout_undo_for_tab_membership(state, &[pane_id]); - return (removed, false); - } + projection_ready.wait(); - // Last tab gone: the pane collapses out of its screen. - state.remove_pane(pane_id); - let Some((wi, si)) = state.screen_of(pane_id) else { - return (removed, false); - }; - let (was_active, screen_remains) = { - let screen = &mut state.workspaces[wi].screens[si]; - let was_active = screen.active_pane == pane_id; - if screen.zoomed_pane == Some(pane_id) { - screen.zoomed_pane = None; - } - let screen_remains = remove_pane_from_screen_layout(mux, screen, pane_id); - (was_active, screen_remains) - }; - if screen_remains { - let next_active = if was_active { - let mut ids = Vec::new(); - state.workspaces[wi].screens[si].root.pane_ids(&mut ids); - most_recent_pane(state, &ids) - } else { - None + let (acquired_tx, acquired_rx) = mpsc::channel(); + let racing_writer = { + let mux = mux.clone(); + std::thread::spawn(move || { + let mut state = mux.state.lock().unwrap(); + state.workspaces[0].name = "Raced after capture".into(); + acquired_tx.send(()).unwrap(); + }) }; - if let Some(next) = next_active { - state.workspaces[wi].screens[si].active_pane = next; + assert!( + acquired_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "a topology writer entered between projection capture and durable commit" + ); + allow_commit.wait(); + let commit = commit_thread.join().unwrap(); + acquired_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + racing_writer.join().unwrap(); + *mux.resource_projection_before_commit.lock().unwrap() = None; + + assert_eq!(commit.revision, before_revision + 1); + assert_eq!(mux.resource_event_epoch(), before_epoch + 1); + let registry = mux.workspace_registry.lock().unwrap(); + let snapshot = registry.snapshot().unwrap(); + assert_eq!(snapshot.resource_revision, before_revision + 1); + assert_eq!(snapshot.workspaces[0].name, original_name); + let events = registry.resource_events_after(before_revision).unwrap(); + assert_eq!(events.batches.len(), 1); + let changes = events.batches[0].changes.as_array().unwrap(); + assert!(!changes.is_empty()); + for (sequence, change) in changes.iter().enumerate() { + assert_eq!(change["sequence"], sequence); + assert!(matches!(change["kind"].as_str(), Some("upsert" | "delete"))); + assert!(change["resource"].is_string()); + assert!(change["id"].is_string()); + assert!(change.get("event").is_none()); } - stamp_changed_active_pane(mux, state, previous_active); - return (removed, true); + surface.kill(); } - // Screen emptied: drop it from the workspace. - let ws = &mut state.workspaces[wi]; - ws.screens.remove(si); - ws.active_screen = ws.active_screen.min(ws.screens.len().saturating_sub(1)); - if !ws.screens.is_empty() { - stamp_changed_active_pane(mux, state, previous_active); - return (removed, true); - } + #[test] + fn ordinary_workspace_mutations_publish_one_immediate_public_revision() { + fn assert_public_state( + mux: &Mux, + revision: u64, + expected: &[(&str, bool)], + ) -> Vec { + assert_eq!(mux.resource_event_epoch(), revision); + mux.with_state(|state| assert_eq!(state.resource_revision, revision)); - // The screen emptied, but the workspace remains as a canonical registry - // entry. Record the resulting loss of active pane without discarding its - // stable workspace identity. - stamp_changed_active_pane(mux, state, previous_active); - (removed, true) -} + let snapshot = crate::resource_api::public_session_snapshot(mux).unwrap(); + assert_eq!(snapshot["session"]["revision"], revision.to_string()); + let workspaces = snapshot["workspaces"].as_array().unwrap(); + assert_eq!(workspaces.len(), expected.len()); + for (index, (workspace, (name, focused))) in + workspaces.iter().zip(expected.iter()).enumerate() + { + assert_eq!(workspace["name"], *name); + assert_eq!(workspace["index"], u64::try_from(index).unwrap()); + assert_eq!(workspace["focused"], *focused); + } -fn collapse_empty_pane(mux: &Mux, state: &mut State, pane_id: PaneId) { - state.remove_pane(pane_id); - let Some((wi, si)) = state.screen_of(pane_id) else { - return; - }; - let (was_active, screen_remains) = { - let screen = &mut state.workspaces[wi].screens[si]; - let was_active = screen.active_pane == pane_id; - if screen.zoomed_pane == Some(pane_id) { - screen.zoomed_pane = None; - } - let screen_remains = remove_pane_from_screen_layout(mux, screen, pane_id); - (was_active, screen_remains) - }; - if screen_remains { - let next_active = if was_active { - let mut ids = Vec::new(); - state.workspaces[wi].screens[si].root.pane_ids(&mut ids); - most_recent_pane(state, &ids) - } else { - None - }; - if let Some(next) = next_active { - state.workspaces[wi].screens[si].active_pane = next; + let registry = mux.workspace_registry.lock().unwrap(); + assert_eq!(registry.snapshot().unwrap().resource_revision, revision); + let events = registry.resource_events_after(0).unwrap(); + assert_eq!(events.batches.len(), usize::try_from(revision).unwrap()); + let batch = events.batches.last().unwrap(); + assert_eq!(batch.previous_revision, revision - 1); + assert_eq!(batch.revision, revision); + for (sequence, change) in batch.changes.as_array().unwrap().iter().enumerate() { + assert_eq!(change["sequence"], sequence); + assert!(matches!(change["kind"].as_str(), Some("upsert" | "delete"))); + assert!(change["resource"].is_string()); + assert!(change["id"].is_string()); + assert!(change.get("event").is_none()); + } + workspaces + .iter() + .map(|workspace| { + WorkspacePublicId::parse(workspace["id"].as_str().unwrap()).unwrap() + }) + .collect() } - } else { - let ws = &mut state.workspaces[wi]; - ws.screens.remove(si); - ws.active_screen = ws.active_screen.min(ws.screens.len().saturating_sub(1)); - } -} -fn move_tab_in_state( - mux: &Mux, - state: &mut State, - surface: SurfaceId, - target_pane: PaneId, - index: usize, -) -> (bool, bool) { - if !state.surfaces.contains_key(&surface) || !state.panes.contains_key(&target_pane) { - return (false, false); + let mux = test_mux(); + let first = mux.create_empty_workspace(Some("One".into()), None, Some(0)).unwrap(); + assert_public_state(&mux, 1, &[("One", true)]); + + let second = mux.create_empty_workspace(Some("Two".into()), None, Some(1)).unwrap(); + let ids = assert_public_state(&mux, 2, &[("One", false), ("Two", true)]); + + assert_eq!( + mux.rename_workspace_at_revision(first.workspace, "Renamed".into(), Some(2)).unwrap(), + Some(3) + ); + assert_public_state(&mux, 3, &[("Renamed", false), ("Two", true)]); + + assert_eq!( + mux.move_workspace_at_revision(first.workspace, 2, Some(3)).unwrap(), + Some((4, true)) + ); + let moved_ids = assert_public_state(&mux, 4, &[("Two", true), ("Renamed", false)]); + assert_eq!(moved_ids, vec![ids[1].clone(), ids[0].clone()]); + + assert_eq!(mux.close_workspace_at_revision(first.workspace, Some(4)).unwrap(), Some(5)); + let remaining = assert_public_state(&mux, 5, &[("Two", true)]); + assert_eq!(remaining, vec![ids[1].clone()]); + assert_eq!(second.workspace, mux.with_state(|state| state.workspaces[0].id)); } - let Some(source_pane) = state.pane_of(surface) else { return (false, false) }; - if source_pane == target_pane { - let Some(pane) = state.panes.get_mut(&target_pane) else { - return (false, false); - }; - let Some(old_idx) = pane.tabs.iter().position(|id| *id == surface) else { - return (false, false); - }; - let new_idx = if index > old_idx { index.saturating_sub(1) } else { index }; - let new_idx = new_idx.min(pane.tabs.len().saturating_sub(1)); - if new_idx == old_idx { - return (false, false); + + fn assert_ordinary_public_revision(mux: &Mux, revision: u64) -> Value { + assert_eq!(mux.resource_event_epoch(), revision); + mux.with_state(|state| assert_eq!(state.resource_revision, revision)); + let snapshot = crate::resource_api::public_session_snapshot(mux).unwrap(); + assert_eq!(snapshot["session"]["revision"], revision.to_string()); + + let registry = mux.workspace_registry.lock().unwrap(); + assert_eq!(registry.resource_topology_snapshot().unwrap().revision, revision); + let events = registry.resource_events_after(0).unwrap(); + assert_eq!(events.batches.len(), usize::try_from(revision).unwrap()); + let batch = events.batches.last().unwrap(); + assert_eq!(batch.previous_revision, revision - 1); + assert_eq!(batch.revision, revision); + let changes = batch.changes.as_array().unwrap(); + assert!(!changes.is_empty()); + for (sequence, change) in changes.iter().enumerate() { + assert_eq!(change["sequence"], sequence); + assert!(matches!(change["kind"].as_str(), Some("upsert" | "delete"))); + assert!(change["resource"].is_string()); + assert!(change["id"].is_string()); + assert!(change.get("event").is_none()); } - let tab = pane.tabs.remove(old_idx); - pane.tabs.insert(new_idx, tab); - pane.active_tab = new_idx; - fence_layout_undo_for_tab_membership(state, &[target_pane]); - return (true, false); + snapshot } - fence_layout_undo_for_tab_membership(state, &[source_pane, target_pane]); - { - let Some(source) = state.panes.get_mut(&source_pane) else { - return (false, false); - }; - let Some(old_idx) = source.tabs.iter().position(|id| *id == surface) else { - return (false, false); - }; - source.tabs.remove(old_idx); - if !source.tabs.is_empty() && source.active_tab >= old_idx && source.active_tab > 0 { - source.active_tab -= 1; - } + #[test] + fn ordinary_topology_creates_renames_and_focus_publish_one_revision_each() { + let mux = test_mux(); + let first = mux.new_workspace(Some("One".into()), None).unwrap(); + assert_ordinary_public_revision(&mux, 1); + let (first_workspace, first_screen, first_pane) = mux.with_state(|state| { + let pane = state.pane_of(first.id).unwrap(); + let (workspace, screen) = state.screen_of(pane).unwrap(); + (state.workspaces[workspace].id, state.workspaces[workspace].screens[screen].id, pane) + }); + + let second_tab = mux.new_tab(Some(first_pane), None, None).unwrap(); + assert_ordinary_public_revision(&mux, 2); + let browser = mux + .new_browser_tab("about:blank#ordinary-public".into(), Some(first_pane), None) + .unwrap(); + assert_ordinary_public_revision(&mux, 3); + + let second_screen_surface = mux.new_screen(Some(first_workspace), None).unwrap(); + assert_ordinary_public_revision(&mux, 4); + let second_pane = mux.with_state(|state| state.pane_of(second_screen_surface.id).unwrap()); + let split_surface = mux.split(second_pane, SplitDir::Right, None).unwrap(); + assert_ordinary_public_revision(&mux, 5); + let split_pane = mux.with_state(|state| state.pane_of(split_surface.id).unwrap()); + let distributed_surface = mux.new_pane(split_pane, None).unwrap(); + assert_ordinary_public_revision(&mux, 6); + let distributed_pane = + mux.with_state(|state| state.pane_of(distributed_surface.id).unwrap()); + + assert!(mux.rename_pane(distributed_pane, "Worker".into())); + assert_ordinary_public_revision(&mux, 7); + assert!(mux.rename_surface(browser.id, "Docs".into())); + assert_ordinary_public_revision(&mux, 8); + let second_screen = mux.with_state(|state| { + let (workspace, screen) = state.screen_of(second_pane).unwrap(); + state.workspaces[workspace].screens[screen].id + }); + assert!(mux.rename_screen(second_screen, "Build".into())); + let snapshot = assert_ordinary_public_revision(&mux, 9); + let pane_public = + mux.with_state(|state| state.resource_indexes.pane_ids[&distributed_pane].to_string()); + let tab_public = + mux.with_state(|state| state.resource_indexes.tab_ids[&browser.id].to_string()); + let screen_public = + mux.with_state(|state| state.resource_indexes.screen_ids[&second_screen].to_string()); + assert_eq!( + snapshot["panes"] + .as_array() + .unwrap() + .iter() + .find(|pane| pane["id"] == pane_public) + .unwrap()["name"], + "Worker" + ); + assert_eq!( + snapshot["tabs"] + .as_array() + .unwrap() + .iter() + .find(|tab| tab["id"] == tab_public) + .unwrap()["name"], + "Docs" + ); + assert_eq!( + snapshot["screens"] + .as_array() + .unwrap() + .iter() + .find(|screen| screen["id"] == screen_public) + .unwrap()["name"], + "Build" + ); + + assert!(mux.focus_pane(second_pane)); + assert_ordinary_public_revision(&mux, 10); + mux.select_tab(Some(first_pane), Some(0), None); + assert_ordinary_public_revision(&mux, 11); + mux.with_state(|state| assert_eq!(state.active_pane(), Some(second_pane))); + mux.select_screen(Some(0), None); + assert_ordinary_public_revision(&mux, 12); + mux.with_state(|state| { + assert_eq!( + state.workspaces[0].screens[state.workspaces[0].active_screen].id, + first_screen + ); + }); + + mux.new_workspace(Some("Two".into()), None).unwrap(); + assert_ordinary_public_revision(&mux, 13); + mux.select_workspace(Some(0), None); + let snapshot = assert_ordinary_public_revision(&mux, 14); + assert_eq!( + snapshot["workspaces"] + .as_array() + .unwrap() + .iter() + .find(|workspace| workspace["name"] == "One") + .unwrap()["focused"], + true + ); + + assert_eq!(second_tab.id, mux.with_state(|state| state.panes[&first_pane].tabs[1])); } - let topology_changed = state.panes.get(&source_pane).is_some_and(|pane| pane.tabs.is_empty()); - if topology_changed { - collapse_empty_pane(mux, state, source_pane); + #[test] + fn durable_workspace_creation_supports_the_in_process_terminal_runtime() { + let mux = Mux::new( + format!("in-process-resource-{}", WorkspacePublicId::random().unwrap()), + SurfaceOptions::default(), + ); + + let surface = mux.new_workspace(Some("headless".into()), Some((80, 24))).unwrap(); + let identity = mux + .resource_terminal_host_identity(&surface) + .expect("reserved in-process terminal has a durable lifecycle identity"); + assert!( + mux.reserved_in_process_terminals.lock().unwrap().contains_key(&surface.id), + "in-process reservation must remain available to close and exit paths" + ); + let snapshot = mux.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); + assert_eq!(snapshot.revision, 1); + assert_eq!(snapshot.active_screens.len(), 1); + assert_eq!(snapshot.tabs.len(), 1); + assert_eq!(snapshot.tabs[0].terminal_id.as_deref(), Some(identity.terminal_id.as_str())); + + let workspace = mux.with_state(|state| state.workspaces[0].id); + assert!(mux.close_workspace(workspace)); + let _ = mux.shutdown(); } - let Some(target) = state.panes.get_mut(&target_pane) else { - return (false, topology_changed); - }; - let new_idx = index.min(target.tabs.len()); - target.tabs.insert(new_idx, surface); - target.active_tab = new_idx; - state.resource_indexes.tab_pane.insert(surface, target_pane); - let destination_path = if let Some((wi, si)) = state.screen_of(target_pane) { - state.active_workspace = wi; - let ws = &mut state.workspaces[wi]; - ws.active_screen = si; - let screen = &mut ws.screens[si]; - screen.active_pane = target_pane; - Some((ws.id, screen.id)) - } else { - None - }; - if let Some((workspace, screen)) = destination_path { - mux.subscribers.update_surface_session_path(surface, workspace, screen, target_pane); + #[cfg(unix)] + #[test] + fn terminal_close_reports_exact_host_record_read_failure() { + let mux = test_mux(); + let surface = mux.new_workspace(Some("exact-host-close".into()), Some((80, 24))).unwrap(); + let identity = mux.resource_terminal_host_identity(&surface).unwrap(); + let root = std::env::temp_dir().join(format!( + "cmux-terminal-close-host-record-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join(format!("{}.json", identity.terminal_id)), b"{invalid-json") + .unwrap(); + mux.surface_options.lock().unwrap().terminal_host_root = Some(root.clone()); + + let result = mux.close_terminal(&identity.terminal_id, &identity.incarnation); + let _ = std::fs::remove_dir_all(root); + + let error = result.expect_err("close acknowledged an unreadable exact host record"); + assert!(format!("{error:#}").contains("terminal-host")); } - (true, topology_changed) -} -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; + #[cfg(unix)] + #[test] + fn prepared_terminal_host_cleanup_acknowledges_exit_after_record_disappears() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; - use crate::layout::{DEFAULT_VIEWPORT_PANE_WIDTH, VirtualRect}; - use crate::resource::{BrowserPublicId, MachinePublicId, SessionPublicId, TabPublicId}; - use crate::workspace_registry::{ - RegistryPane, RegistryScreen, RegistryViewportColumn, ResourceChange, ResourcePatch, - }; + let mux = test_mux(); + let root = std::env::temp_dir().join(format!( + "cmux-prepared-host-exit-cleanup-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let terminal_id = TerminalId::random().unwrap().to_hex(); + let incarnation = TerminalId::random().unwrap().to_hex(); + let uid = std::fs::metadata(&root).unwrap().uid(); + let record = crate::terminal_host_runtime::TerminalHostRecord { + record_version: 4, + terminal_id: terminal_id.clone(), + incarnation: incarnation.clone(), + endpoint: format!("/tmp/cmux-th-{uid}/{terminal_id}.sock"), + owner_token: "01".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: std::process::id(), + host_start_nonce: "02".repeat(32), + workspace_key: String::new(), + supports_set_defaults: true, + supports_terminate_only: true, + supports_clear_history: true, + supports_terminate_ack: true, + }; + let record_path = record.record_path(&root); + let exit_record = crate::terminal_host_runtime::TerminalHostExitRecord::new( + &TerminalHostIdentity { terminal_id, incarnation }, + TerminalExit { + outcome: crate::terminal_host_protocol::TerminalExitOutcome::Signal { + signal: libc::SIGHUP, + core_dumped: false, + }, + exited_at_ms: 1_234_567, + }, + ); + let exit_path = record_path.with_extension("exit"); + std::fs::write(&exit_path, serde_json::to_vec(&exit_record).unwrap()).unwrap(); + std::fs::set_permissions(&exit_path, std::fs::Permissions::from_mode(0o600)).unwrap(); - fn test_mux() -> Arc { - Mux::new_for_test("test", SurfaceOptions::default()) + mux.terminate_prepared_terminal_hosts(vec![(record_path, record)]).unwrap(); + + let exit_remained = exit_path.exists(); + let _ = std::fs::remove_dir_all(root); + assert!(!exit_remained, "post-commit host cleanup retained its exact exit sidecar"); } - fn assert_terminal_view_detached(mux: &Mux, surface: SurfaceId) { - assert!(!mux.with_state(|state| state.surfaces.contains_key(&surface))); - assert!(mux.surface(surface).is_some(), "terminal runtime must remain catalog-owned"); + #[cfg(unix)] + #[test] + fn workspace_close_reports_exact_host_record_read_failure() { + let mux = test_mux(); + let surface = + mux.new_workspace(Some("exact-workspace-close".into()), Some((80, 24))).unwrap(); + let identity = mux.resource_terminal_host_identity(&surface).unwrap(); + let workspace = mux.with_state(|state| state.workspaces[0].id); + let root = std::env::temp_dir().join(format!( + "cmux-workspace-close-host-record-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join(format!("{}.json", identity.terminal_id)), b"{invalid-json") + .unwrap(); + mux.surface_options.lock().unwrap().terminal_host_root = Some(root.clone()); + + let result = mux.close_workspace_at_revision(workspace, None); + let workspace_remained = + mux.with_state(|state| state.workspaces.iter().any(|entry| entry.id == workspace)); + let terminal_lifecycle = mux + .workspace_registry + .lock() + .unwrap() + .terminal_record(&identity.terminal_id) + .unwrap() + .unwrap() + .lifecycle; + let _ = std::fs::remove_dir_all(root); + + let error = result.expect_err("workspace close acknowledged an unreadable host record"); + assert!(format!("{error:#}").contains("terminal-host")); + assert!(workspace_remained, "failed host validation committed the workspace close"); + assert_ne!( + terminal_lifecycle, + TerminalLifecycle::Tombstoned, + "failed host validation tombstoned the target terminal" + ); } - fn wait_for_kitty_image_budget(mux: &Mux) { - // The stress cases create every process-budget owner while the rest - // of this 800-test binary is also scheduling worker threads. Wait on - // the worker's state transition instead of polling CPU progress. + #[cfg(unix)] + #[test] + fn workspace_close_ignores_unrelated_malformed_host_record() { + let mux = test_mux(); + let target = mux.new_workspace(Some("target-workspace".into()), Some((80, 24))).unwrap(); + let unrelated = + mux.new_workspace(Some("unrelated-workspace".into()), Some((80, 24))).unwrap(); + let target_workspace = mux.with_state(|state| { + let pane = state.pane_of(target.id).unwrap(); + let (workspace, _) = state.screen_of(pane).unwrap(); + state.workspaces[workspace].id + }); + let unrelated_identity = mux.resource_terminal_host_identity(&unrelated).unwrap(); + let root = std::env::temp_dir().join(format!( + "cmux-workspace-close-unrelated-record-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let unrelated_path = root.join(format!("{}.json", unrelated_identity.terminal_id)); + std::fs::write(&unrelated_path, b"{invalid-json").unwrap(); + mux.surface_options.lock().unwrap().terminal_host_root = Some(root.clone()); + + let result = mux.close_workspace_at_revision(target_workspace, None); + let target_remained = mux + .with_state(|state| state.workspaces.iter().any(|entry| entry.id == target_workspace)); + let unrelated_remained = mux.with_state(|state| state.surfaces.contains_key(&unrelated.id)); + let unrelated_record_remained = unrelated_path.exists(); + let _ = std::fs::remove_dir_all(root); + + result.expect("an unrelated malformed host record blocked workspace close"); + assert!(!target_remained, "successful close retained its target workspace"); + assert!(unrelated_remained, "targeted close removed an unrelated terminal"); assert!( - mux.wait_for_kitty_image_budget_idle_for_test( - crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT.saturating_mul(15), - ), - "Kitty image budget worker did not converge" + unrelated_record_remained, + "targeted close removed an unrelated malformed host record" ); } - fn close_terminal_runtime_for_test(mux: &Mux, surface: &Surface) { - let identity = mux - .resource_terminal_host_identity(surface) - .expect("test terminal has a host identity"); - mux.close_terminal(&identity.terminal_id, &identity.incarnation).unwrap(); + #[test] + fn local_workspace_close_retries_cleanup_from_its_committed_receipt() { + let mux = test_mux(); + let surface = mux.new_workspace(Some("local-cleanup-retry".into()), None).unwrap(); + let workspace = mux.with_state(|state| state.workspaces[0].id); + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.terminal_host_cleanup_hook.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move || attempts.fetch_add(1, Ordering::SeqCst) != 0 + })); + + let error = mux.close_workspace_at_revision(workspace, None).unwrap_err(); + assert!(error.to_string().contains("forced terminal-host cleanup failure")); + assert!(mux.with_state(|state| state.workspaces.is_empty())); + let committed_revision = + mux.workspace_registry.lock().unwrap().snapshot().unwrap().revision; + + assert_eq!( + mux.close_workspace_at_revision(workspace, None).unwrap(), + Some(committed_revision) + ); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + *mux.terminal_host_cleanup_hook.lock().unwrap() = None; + surface.kill(); + mux.shutdown().unwrap(); } - fn public_request( - mux: &Arc, - id: &str, - operation: &str, - params: Value, - idempotency_key: Option<&str>, - ) -> Value { - let mut request = serde_json::json!({ - "protocol":"cmux.protocol/2", - "type":"request", - "id":id, - "operation":operation, - "params":params, + #[test] + fn resource_workspace_close_retries_cleanup_before_committed_success() { + let mux = test_mux(); + let surface = mux.new_workspace(Some("resource-cleanup-retry".into()), None).unwrap(); + let (workspace_id, workspace) = mux.with_state(|state| { + (state.workspaces[0].id, state.workspaces[0].public_id.to_string()) + }); + let selectors = mux.ordinary_workspace_selectors(workspace_id).unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.terminal_host_cleanup_hook.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move || attempts.fetch_add(1, Ordering::SeqCst) != 0 + })); + let params = serde_json::json!({ + "machine":"current", + "session":"current", + "workspace":workspace, }); - if let Some(idempotency_key) = idempotency_key { - request["idempotency_key"] = Value::String(idempotency_key.to_string()); - } - crate::resource_router::handle_resource_message(mux, &request.to_string()).unwrap() - } - fn state_topology_fingerprint(state: &State) -> String { - let workspaces = state - .workspaces - .iter() - .map(|workspace| { - ( - workspace.id, - workspace.public_id.clone(), - workspace.key.clone(), - workspace.name.clone(), - workspace.active_screen, - workspace - .screens - .iter() - .map(|screen| { - ( - screen.id, - screen.public_id.clone(), - screen.layout_revision, - format!("{:?}", screen.layout_snapshot()), - format!("{:?}", screen.layout_undo), - ) - }) - .collect::>(), - ) - }) - .collect::>(); - let mut panes = state - .panes - .iter() - .map(|(id, pane)| (*id, pane.tabs.clone(), pane.active_tab)) - .collect::>(); - panes.sort_by_key(|(id, _, _)| *id); - let mut surfaces = state.surfaces.keys().copied().collect::>(); - surfaces.sort_unstable(); - format!( - "{:?}", - ( - state.resource_revision, - state.workspace_revision, - state.active_workspace, - workspaces, - panes, - surfaces, - ) - ) + let first = public_request( + &mux, + "cleanup-first", + "workspace.close", + params.clone(), + Some("cleanup-retry-effect"), + ); + assert!(first.get("error").is_some(), "postcommit cleanup failure was acknowledged"); + assert!(mux.with_state(|state| state.workspaces.is_empty())); + let fingerprint = serde_json::json!({ + "operation":"workspace.close", + "selectors":selectors, + "fields":{}, + }); + let preparation = mux + .workspace_registry + .lock() + .unwrap() + .lookup_resource_effect("cleanup-retry-effect", "workspace.close", &fingerprint) + .unwrap() + .unwrap(); + let ResourceEffectPreparation::Committed { + outcome: ResourceEffectOutcome::Success(stored_result), + .. + } = preparation + else { + panic!("postcommit cleanup failure lost its committed success receipt"); + }; + assert_eq!(terminal_host_cleanup_identities(&stored_result).unwrap().len(), 1); + + let replay = public_request( + &mux, + "cleanup-replay", + "workspace.close", + params, + Some("cleanup-retry-effect"), + ); + assert_eq!(replay["result"]["replayed"], true); + assert!( + !replay.to_string().contains(TERMINAL_HOST_CLEANUP_RECEIPT_FIELD), + "public replay leaked its internal cleanup receipt" + ); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + *mux.terminal_host_cleanup_hook.lock().unwrap() = None; + surface.kill(); + mux.shutdown().unwrap(); } - fn restore_workspace_id(value: u128) -> WorkspacePublicId { - WorkspacePublicId::parse(format!("ws_{value:032x}")).unwrap() - } + #[cfg(unix)] + #[test] + fn workspace_close_cleanup_is_driven_by_live_host_records() { + let mux = test_mux(); + let surface = + mux.new_workspace(Some("bounded-workspace-close".into()), Some((80, 24))).unwrap(); + let workspace = mux.with_state(|state| state.workspaces[0].id); + let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + for _ in 0..32 { + let historical = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + let identity = mux.resource_terminal_host_identity(&historical).unwrap(); + mux.close_terminal(&identity.terminal_id, &identity.incarnation).unwrap(); + } + let _ = mux.take_discovered_terminal_termination_requests_for_test(); - fn restore_screen_id(value: u128) -> ScreenPublicId { - ScreenPublicId::parse(format!("screen_{value:032x}")).unwrap() - } + let root = std::env::temp_dir().join(format!( + "cmux-workspace-close-live-records-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + mux.surface_options.lock().unwrap().terminal_host_root = Some(root.clone()); - fn restore_pane_id(value: u128) -> PanePublicId { - PanePublicId::parse(format!("pane_{value:032x}")).unwrap() - } + let result = mux.close_workspace_at_revision(workspace, None); + let requested = mux.take_discovered_terminal_termination_requests_for_test(); + let _ = std::fs::remove_dir_all(root); - fn restore_tab_id(value: u128) -> TabPublicId { - TabPublicId::parse(format!("tab_{value:032x}")).unwrap() + result.unwrap(); + assert!( + requested.is_empty(), + "workspace close probed {} historical tombstone(s) without live host records", + requested.len() + ); } - fn restore_browser_id(value: u128) -> BrowserPublicId { - BrowserPublicId::parse(format!("browser_{value:032x}")).unwrap() - } + #[test] + fn ordinary_topology_projection_failure_keeps_memory_and_public_state_unchanged() { + let mux = test_mux(); + let surface = mux.new_workspace(None, None).unwrap(); + let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); - fn restore_terminal_id(value: u128) -> TerminalPublicId { - TerminalPublicId::parse(format!("term_{value:032x}")).unwrap() - } + assert!(!mux.rename_pane(pane, "Never visible".into())); - fn restore_split_id(value: u128) -> SplitPublicId { - SplitPublicId::parse(format!("split_{value:032x}")).unwrap() + assert_eq!(mux.resource_event_epoch(), 1); + mux.with_state(|state| { + assert_eq!(state.resource_revision, 1); + assert_eq!(state.panes[&pane].name, None); + }); + let registry = mux.workspace_registry.lock().unwrap(); + assert_eq!(registry.resource_topology_snapshot().unwrap().revision, 1); + assert_eq!(registry.resource_events_after(0).unwrap().batches.len(), 1); + registry.set_resource_patch_failure(false).unwrap(); } - fn resource_restore_fixture() -> (RegistrySnapshot, ResourceTopologySnapshot) { - let first_workspace = RegistryWorkspace { - id: 10, - public_id: restore_workspace_id(1), - key: "first".into(), - name: "Duplicate".into(), - group_key: "test".into(), - }; - let empty_workspace = RegistryWorkspace { - id: 20, - public_id: restore_workspace_id(2), - key: "empty".into(), - name: "Duplicate".into(), - group_key: "test".into(), - }; - let first_screen = restore_screen_id(1); - let second_screen = restore_screen_id(2); - let panes = (1..=5).map(restore_pane_id).collect::>(); - let tabs = (1..=6).map(restore_tab_id).collect::>(); - let internal_split = restore_split_id(1); - let boundary_split = restore_split_id(2); - let base_column = restore_split_id(3); - let first_column_layout = RegistryLayoutNode::Split { - split: internal_split, - direction: "down".into(), - ratio: 0.6, - first: Box::new(RegistryLayoutNode::Stack { - panes: vec![panes[0].clone(), panes[1].clone()], - expanded: panes[1].clone(), - }), - second: Box::new(RegistryLayoutNode::Leaf { pane: panes[2].clone() }), - }; - let first_layout = RegistryLayoutNode::Split { - split: boundary_split.clone(), - direction: "right".into(), - ratio: 0.8 / (0.8 + 0.4), - first: Box::new(first_column_layout.clone()), - second: Box::new(RegistryLayoutNode::Leaf { pane: panes[3].clone() }), - }; - let screens = vec![ - RegistryScreen { - public_id: first_screen.clone(), - workspace_id: first_workspace.public_id.clone(), - position: 0, - name: Some("Columns".into()), - layout: first_layout, - active_pane: panes[2].clone(), - zoomed_pane: Some(panes[2].clone()), - auto_layout: None, - viewport: RegistryViewport { - base_width: Some(0.8), - columns: vec![ - RegistryViewportColumn { - id: base_column, - width: 0.8, - layout: first_column_layout, - auto_layout: None, - }, - RegistryViewportColumn { - id: boundary_split, - width: 0.4, - layout: RegistryLayoutNode::Leaf { pane: panes[3].clone() }, - auto_layout: Some(vec![panes[3].clone()]), - }, - ], - }, - }, - RegistryScreen { - public_id: second_screen.clone(), - workspace_id: first_workspace.public_id.clone(), - position: 1, - name: Some("Selected".into()), - layout: RegistryLayoutNode::Leaf { pane: panes[4].clone() }, - active_pane: panes[4].clone(), - zoomed_pane: Some(panes[4].clone()), - auto_layout: Some(vec![panes[4].clone()]), - viewport: RegistryViewport::default(), - }, - ]; - let registry_panes = vec![ - RegistryPane { - public_id: panes[0].clone(), - screen_id: first_screen.clone(), - name: Some("one".into()), - active_tab: Some(tabs[0].clone()), - creation_ordinal: 1, - }, - RegistryPane { - public_id: panes[1].clone(), - screen_id: first_screen.clone(), - name: Some("two".into()), - active_tab: Some(tabs[1].clone()), - creation_ordinal: 2, - }, - RegistryPane { - public_id: panes[2].clone(), - screen_id: first_screen.clone(), - name: Some("three".into()), - active_tab: Some(tabs[2].clone()), - creation_ordinal: 3, - }, - RegistryPane { - public_id: panes[3].clone(), - screen_id: first_screen, - name: Some("four".into()), - active_tab: Some(tabs[3].clone()), - creation_ordinal: 4, - }, - RegistryPane { - public_id: panes[4].clone(), - screen_id: second_screen.clone(), - name: Some("five".into()), - active_tab: Some(tabs[5].clone()), - creation_ordinal: 5, - }, - ]; - let registry_tabs = tabs - .iter() - .enumerate() - .map(|(index, tab)| { - let pane_index = index.min(4); - RegistryTab { - public_id: tab.clone(), - pane_id: panes[pane_index].clone(), - position: usize::from(index == 5), - content_id: ContentPublicId::Browser(restore_browser_id(index as u128 + 1)), - name: Some(format!("tab-{index}")), - browser_url: Some(format!("about:blank#{index}")), - terminal_id: None, - } - }) - .collect::>(); - let browsers = registry_tabs - .iter() - .enumerate() - .map(|(index, tab)| { - let ContentPublicId::Browser(public_id) = &tab.content_id else { - unreachable!("restart fixture uses browser tabs"); - }; - RegistryBrowser { - public_id: public_id.clone(), - url: tab.browser_url.clone().unwrap(), - source: if index % 2 == 0 { - crate::workspace_registry::RegistryBrowserSource::Launched - } else { - crate::workspace_registry::RegistryBrowserSource::External - }, - launch: if index % 2 == 0 { - crate::workspace_registry::RegistryBrowserLaunch::Create - } else { - crate::workspace_registry::RegistryBrowserLaunch::Adopted - }, - reconnect: RegistryBrowserReconnect::Recreate, - status: if index % 2 == 0 { - crate::workspace_registry::RegistryBrowserStatus::Live - } else { - crate::workspace_registry::RegistryBrowserStatus::Failed - }, - cols: 90 + index as u16, - rows: 30 + index as u16, - } - }) - .collect(); - let session_id = - SessionPublicId::parse("session_00000000000000000000000000000001").unwrap(); - ( - RegistrySnapshot { - registry_id: "registry".into(), - generation: "generation".into(), - revision: 1, - resource_revision: 1, - session_id: session_id.clone(), - next_numeric_id: 100, - workspaces: vec![first_workspace.clone(), empty_workspace.clone()], - }, - ResourceTopologySnapshot { - session_id, - generation: "generation".into(), - revision: 1, - active_workspace: Some(empty_workspace.public_id.clone()), - active_screens: vec![ - (first_workspace.public_id, Some(second_screen)), - (empty_workspace.public_id, None), - ], - screens, - panes: registry_panes, - tabs: registry_tabs, - browsers, - }, - ) + #[test] + fn ordinary_workspace_projection_failure_rolls_back_both_registries_and_memory() { + let mux = test_mux(); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); + + let error = + mux.create_empty_workspace(Some("Never visible".into()), None, Some(0)).unwrap_err(); + assert!(error.to_string().contains("forced resource patch failure")); + assert_eq!(mux.resource_event_epoch(), 0); + mux.with_state(|state| { + assert!(state.workspaces.is_empty()); + assert_eq!(state.workspace_revision, 0); + assert_eq!(state.resource_revision, 0); + }); + let registry = mux.workspace_registry.lock().unwrap(); + let snapshot = registry.snapshot().unwrap(); + assert_eq!(snapshot.revision, 0); + assert_eq!(snapshot.resource_revision, 0); + assert!(snapshot.workspaces.is_empty()); + assert!(registry.resource_events_after(0).unwrap().batches.is_empty()); + registry.set_resource_patch_failure(false).unwrap(); } - fn selector_fixture() - -> (RestoredResourceState, MachinePublicId, SessionPublicId, ResourceTopologySnapshot) { - let (snapshot, topology) = resource_restore_fixture(); - let session = snapshot.session_id.clone(); - let restored = restore_resource_state(snapshot, topology.clone()).unwrap(); - (restored, MachinePublicId::random().unwrap(), session, topology) + #[test] + fn resource_idempotency_is_session_global_and_rejects_changed_input() { + let mux = test_mux(); + let first = WorkspaceMutation::new("global-key", "client-a").unwrap(); + mux.resource_create_empty_workspace(Some("One".into()), None, Some(0), &first).unwrap(); + let reused = WorkspaceMutation::new("global-key", "client-b").unwrap(); + let error = mux + .resource_create_empty_workspace(Some("Two".into()), None, Some(1), &reused) + .unwrap_err(); + assert!(error.to_string().contains("idempotency.conflict")); + mux.with_state(|state| { + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].name, "One"); + }); } - fn routed_selectors( - machine: &MachinePublicId, - session: &SessionPublicId, - ) -> crate::ResourceSelectors { - crate::ResourceSelectors { - machine: Some(machine.to_string()), - session: Some(session.to_string()), - ..crate::ResourceSelectors::default() + #[test] + fn resource_results_never_expose_numeric_workspace_slots() { + let mux = test_mux(); + let result = mux + .resource_create_empty_workspace( + Some("Public".into()), + None, + Some(0), + &WorkspaceMutation::new("public-only", "test").unwrap(), + ) + .unwrap() + .result; + let object = result.as_object().unwrap(); + assert_eq!( + object.keys().map(String::as_str).collect::>(), + HashSet::from(["workspace", "name", "index"]) + ); + assert!(WorkspacePublicId::parse(object["workspace"].as_str().unwrap()).is_ok()); + let encoded = serde_json::to_string(&result).unwrap(); + for forbidden in ["key", "workspace_key", "slot", "numeric_id", "short_id", "surface"] { + assert!(!encoded.contains(forbidden), "leaked {forbidden}: {encoded}"); } } #[test] - fn direct_browser_id_derives_its_complete_path_without_structural_selectors() { - let (restored, machine, session, topology) = selector_fixture(); - let browser = topology.browsers[0].public_id.clone(); - let tab = topology - .tabs - .iter() - .find(|tab| tab.content_id == ContentPublicId::Browser(browser.clone())) + fn resource_large_workspace_mutations_are_targeted_and_query_bounded() { + const WORKSPACE_COUNT: usize = 1_000; + let mux = test_mux(); + let mut durable = Vec::with_capacity(WORKSPACE_COUNT); + let mut memory = Vec::with_capacity(WORKSPACE_COUNT); + let mut order = Vec::with_capacity(WORKSPACE_COUNT); + for index in 0..WORKSPACE_COUNT { + let public_id = restore_workspace_id(index as u128 + 1); + let key = format!("00000000-0000-4000-8000-{index:012x}"); + let slot = mux.next_id(); + let name = format!("Workspace {}", index + 1); + durable.push(ResourceChange::UpsertWorkspace { + workspace: RegistryWorkspace { + id: slot, + public_id: public_id.clone(), + key: key.clone(), + name: name.clone(), + group_key: mux.session.clone(), + }, + position: index, + active_screen: None, + }); + memory.push(Workspace { + id: slot, + public_id: public_id.clone(), + key, + name, + screens: Vec::new(), + active_screen: 0, + }); + order.push(public_id); + } + durable.push(ResourceChange::SetWorkspaceOrder { workspace_ids: order.clone() }); + durable.push(ResourceChange::SetActiveWorkspace { workspace_id: order.first().cloned() }); + { + let mut registry = mux.workspace_registry.lock().unwrap(); + let commit = registry + .commit_resource_patch( + &WorkspaceMutation::new("seed-thousand", "test").unwrap(), + "session.seed", + &serde_json::json!({"count":WORKSPACE_COUNT}), + None, + Some(0), + &ResourcePatch { changes: durable }, + &serde_json::json!({"count":WORKSPACE_COUNT}), + &serde_json::json!([{"kind":"session.seeded"}]), + ) + .unwrap(); + let mut state = mux.state.lock().unwrap(); + state.workspaces.reserve(WORKSPACE_COUNT); + state.workspace_index_by_id.reserve(WORKSPACE_COUNT); + state.workspace_id_by_key.reserve(WORKSPACE_COUNT); + state.resource_indexes.workspaces.reserve(WORKSPACE_COUNT); + state.resource_indexes.workspace_ids.reserve(WORKSPACE_COUNT); + for workspace in memory { + state.push_workspace(workspace); + } + state.active_workspace = 0; + state.resource_revision = commit.revision; + } + + let target = order[499].clone(); + let renamed = mux + .resource_rename_workspace( + &target, + "Renamed".into(), + None, + Some(1), + &WorkspaceMutation::new("rename-thousand", "test").unwrap(), + ) .unwrap(); - let pane = topology.panes.iter().find(|pane| pane.public_id == tab.pane_id).unwrap(); - let screen = - topology.screens.iter().find(|screen| screen.public_id == pane.screen_id).unwrap(); - let mut selectors = routed_selectors(&machine, &session); - selectors.browser = Some(browser.to_string()); + assert_eq!(renamed.revision, 2); + assert_eq!( + mux.last_resource_mutation_metrics(), + ResourceMutationMetrics { + touched_resources: 1, + order_entries: 0, + terminal_queries: 0, + changed_rows: 1, + } + ); + mux.with_state(|state| { + assert_eq!(state.workspace_by_public_id(&target).unwrap().name, "Renamed"); + }); - let resolved = resolve_resource_selectors( - &restored.state, - ResourceSelectorContext { - machine_id: &machine, - machine_name: None, - session_id: &session, - session_name: "test", - }, - crate::ResourceTarget::Browser, - &selectors, - ) - .unwrap() - .path; - assert_eq!(resolved.workspace, Some(screen.workspace_id.clone())); - assert_eq!(resolved.screen, Some(screen.public_id.clone())); - assert_eq!(resolved.pane, Some(pane.public_id.clone())); - assert_eq!(resolved.tab, Some(tab.public_id.clone())); - assert_eq!(resolved.browser, Some(browser)); + let moved = mux + .resource_move_workspace( + &target, + WORKSPACE_COUNT - 1, + None, + Some(2), + &WorkspaceMutation::new("move-thousand", "test").unwrap(), + ) + .unwrap(); + assert_eq!(moved.revision, 3); + assert_eq!( + mux.last_resource_mutation_metrics(), + ResourceMutationMetrics { + touched_resources: 1, + order_entries: WORKSPACE_COUNT, + terminal_queries: 0, + changed_rows: WORKSPACE_COUNT, + } + ); + mux.with_state(|state| { + assert_eq!(state.workspaces.last().unwrap().public_id, target); + assert_eq!(state.workspaces.len(), WORKSPACE_COUNT); + }); + let registry = mux.workspace_registry.lock().unwrap().snapshot().unwrap(); + assert_eq!(registry.resource_revision, 3); + assert_eq!(registry.workspaces.last().unwrap().public_id, target); + assert_eq!(registry.workspaces.last().unwrap().name, "Renamed"); + } + + #[test] + fn resource_startup_revalidates_exact_layout_and_viewport_coverage() { + let (snapshot, mut topology) = resource_restore_fixture(); + topology.panes[3].screen_id = restore_screen_id(2); + assert_eq!( + restore_resource_state(snapshot.clone(), topology).err().unwrap().to_string(), + format!("screen {} layout does not cover its panes exactly once", restore_screen_id(1)) + ); + + let (_, mut topology) = resource_restore_fixture(); + topology.screens[0].viewport.columns[1].id = restore_split_id(999); + assert!( + restore_resource_state(snapshot, topology) + .err() + .unwrap() + .to_string() + .contains("unknown projected split") + ); + } + + fn resource_restore_patch( + snapshot: &RegistrySnapshot, + topology: &ResourceTopologySnapshot, + ) -> ResourcePatch { + let active_screens = topology.active_screens.iter().cloned().collect::>(); + let mut changes = Vec::new(); + for (position, workspace) in snapshot.workspaces.iter().enumerate() { + changes.push(ResourceChange::UpsertWorkspace { + workspace: workspace.clone(), + position, + active_screen: active_screens.get(&workspace.public_id).cloned().flatten(), + }); + } + changes.extend(topology.screens.iter().cloned().map(ResourceChange::UpsertScreen)); + changes.extend(topology.panes.iter().cloned().map(ResourceChange::UpsertPane)); + changes.extend(topology.browsers.iter().cloned().map(ResourceChange::UpsertBrowser)); + for tab in &topology.tabs { + let ContentPublicId::Browser(_) = &tab.content_id else { + panic!("restart fixture uses browser tabs"); + }; + changes.push(ResourceChange::UpsertTab(tab.clone())); + } + changes.push(ResourceChange::SetWorkspaceOrder { + workspace_ids: snapshot + .workspaces + .iter() + .map(|workspace| workspace.public_id.clone()) + .collect(), + }); + for workspace in &snapshot.workspaces { + changes.push(ResourceChange::SetScreenOrder { + workspace_id: workspace.public_id.clone(), + screen_ids: topology + .screens + .iter() + .filter(|screen| screen.workspace_id == workspace.public_id) + .map(|screen| screen.public_id.clone()) + .collect(), + }); + } + for pane in &topology.panes { + changes.push(ResourceChange::SetTabOrder { + pane_id: pane.public_id.clone(), + tab_ids: topology + .tabs + .iter() + .filter(|tab| tab.pane_id == pane.public_id) + .map(|tab| tab.public_id.clone()) + .collect(), + }); + } + changes.push(ResourceChange::SetActiveWorkspace { + workspace_id: topology.active_workspace.clone(), + }); + ResourcePatch { changes } } #[test] - fn terminal_selector_uses_ancestor_scope_and_rejects_scope_after_last_view_closes() { - let mux = test_mux(); - let source = mux.new_workspace(Some("source".into()), None).unwrap(); - let destination_anchor = mux.new_workspace(Some("destination".into()), None).unwrap(); - let terminal_id = source.terminal_public_id().cloned().unwrap(); - let (source_workspace, source_pane, destination_workspace, destination_pane) = mux - .with_state(|state| { - let source_pane = state.pane_of(source.id).unwrap(); - let destination_pane = state.pane_of(destination_anchor.id).unwrap(); - let source_workspace = - state.workspaces[state.screen_of(source_pane).unwrap().0].public_id.clone(); - let destination_workspace = state.workspaces - [state.screen_of(destination_pane).unwrap().0] - .public_id - .clone(); - (source_workspace, source_pane, destination_workspace, destination_pane) - }); - // Keep both workspaces live after every view of `terminal_id` closes. - mux.new_tab(Some(source_pane), None, None).unwrap(); + fn persistent_mux_restart_keeps_public_topology_and_browser_tabs() { + let root = std::env::temp_dir() + .join(format!("cmux-resource-restart-{}", WorkspacePublicId::random().unwrap())); + let (fixture_snapshot, fixture_topology) = resource_restore_fixture(); + { + let mut registry = WorkspaceRegistry::open(&root, "restart").unwrap(); + registry + .commit_resource_patch( + &WorkspaceMutation::new("seed-restart", "test").unwrap(), + "session.restore_fixture", + &serde_json::json!({"fixture":"nested-columns"}), + None, + Some(0), + &resource_restore_patch(&fixture_snapshot, &fixture_topology), + &serde_json::json!({"restored":true}), + &serde_json::json!([{"event":"session.restored"}]), + ) + .unwrap(); + } - let terminal_selectors = crate::ResourceSelectors { - terminal: Some(terminal_id.to_string()), - ..Mux::ordinary_resource_selectors() - }; - mux.resource_project_terminal_selected( - terminal_selectors.clone(), - mux.ordinary_pane_selectors(destination_pane).unwrap(), - usize::MAX, - Some("second view".into()), - None, - &WorkspaceMutation::local("selector-multiview"), + let registry = WorkspaceRegistry::open(&root, "restart").unwrap(); + let mux = Mux::from_workspace_registry( + "restart".into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, ) .unwrap(); - - for expected_workspace in [&source_workspace, &destination_workspace] { - let mut scoped = terminal_selectors.clone(); - scoped.workspace = Some(expected_workspace.to_string()); - let path = mux.resolve_resource_path(crate::ResourceTarget::Terminal, &scoped).unwrap(); - assert_eq!(path.workspace.as_ref(), Some(expected_workspace)); - assert_eq!(path.terminal.as_ref(), Some(&terminal_id)); - } - - let placements = mux.with_state(|state| { - state.placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())).to_vec() + mux.with_state(|state| { + assert_eq!(state.workspaces.len(), 2); + assert_eq!(state.active_workspace, 1); + assert!(state.workspaces[1].screens.is_empty()); + assert_eq!(state.workspaces[0].active_screen, 1); + assert_eq!(state.workspaces[0].screens[0].layout_columns.len(), 2); + assert!(state.workspaces[0].screens[0].layout_column_projection_is_consistent()); + assert_eq!(state.surfaces.len(), fixture_topology.tabs.len()); + for tab in &fixture_topology.tabs { + let slot = state.resource_indexes.tabs[&tab.public_id]; + let surface = &state.surfaces[&slot]; + let ContentPublicId::Browser(browser_id) = &tab.content_id else { + unreachable!("restart fixture uses browser tabs"); + }; + let expected_browser = fixture_topology + .browsers + .iter() + .find(|browser| &browser.public_id == browser_id) + .unwrap(); + assert_eq!(surface.resource_identity().unwrap().tab_id, tab.public_id); + assert_eq!(surface.resource_identity().unwrap().content_id, tab.content_id); + assert_eq!(surface.name(), tab.name); + assert_eq!(surface.browser_url().as_deref(), Some(expected_browser.url.as_str())); + assert_eq!(surface.size(), (expected_browser.cols, expected_browser.rows)); + } }); - assert_eq!(placements.len(), 2); - for placement in placements { - assert!(mux.close_surface(placement).unwrap()); - } + let _ = mux.shutdown(); + drop(mux); + std::fs::remove_dir_all(root).unwrap(); + } - let path = mux - .resolve_resource_path(crate::ResourceTarget::Terminal, &terminal_selectors) - .unwrap(); - assert_eq!(path.workspace, None); - assert_eq!(path.tab, None); - assert_eq!(path.terminal, Some(terminal_id)); + #[test] + fn restart_finishes_a_close_committed_before_live_state_detach() { + let root = std::env::temp_dir() + .join(format!("cmux-resource-close-crash-{}", WorkspacePublicId::random().unwrap())); + let session = "resource-close-crash"; + let session_selectors = serde_json::json!({"machine":"current","session":"current"}); + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let created = public_request( + &mux, + "create-workspace", + "workspace.create", + serde_json::json!({ + "machine":"current", + "session":"current", + "name":"close crash", + "initial_content":"empty", + }), + Some("close-crash-create"), + ); + let workspace = created["result"]["value"]["workspace_id"].as_str().unwrap().to_string(); + let before_revision = + created["result"]["revision"].as_str().unwrap().parse::().unwrap(); + mux.set_resource_close_after_commit_hook_for_test(Some(Arc::new(|| { + panic!("simulated daemon crash after close commit") + }))); + let crashed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + public_request( + &mux, + "close-workspace", + "workspace.close", + serde_json::json!({ + "machine":"current", + "session":"current", + "workspace":workspace, + }), + Some("close-crash-effect"), + ) + })); + assert!(crashed.is_err()); + drop(mux); - let mut scoped = terminal_selectors; - scoped.workspace = Some(destination_workspace.to_string()); - let error = - mux.resolve_resource_path(crate::ResourceTarget::Terminal, &scoped).unwrap_err(); - assert_eq!(error.code, "selector.wrong_parent"); - assert_eq!(error.details["scope"], "terminal"); - assert_eq!(error.details["actual_parent"], ""); + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let reopened = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let snapshot = crate::resource_api::public_session_snapshot(&reopened).unwrap(); + assert!(snapshot["workspaces"].as_array().unwrap().is_empty()); + let events = reopened.resource_events_after(before_revision).unwrap(); + assert_eq!(events.batches.len(), 1); + assert_eq!(events.batches[0].revision, before_revision + 1); + let replay = public_request( + &reopened, + "close-replay", + "workspace.close", + serde_json::json!({ + "machine":"current", + "session":"current", + "workspace":workspace, + }), + Some("close-crash-effect"), + ); + assert_eq!(replay["result"]["replayed"], true); + assert_eq!(replay["result"]["revision"], (before_revision + 1).to_string()); + assert_eq!(reopened.resource_events_after(before_revision).unwrap().batches.len(), 1); + assert_eq!( + public_request(&reopened, "snapshot", "session.snapshot", session_selectors, None,)["result"] + ["workspaces"], + serde_json::json!([]) + ); + let _ = reopened.shutdown(); + drop(reopened); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn terminal_projection_event_matches_every_changed_public_snapshot() { + fn terminal_close_runs_runtime_cleanup_outside_creation_fence() { let mux = test_mux(); - let source = mux.new_workspace(Some("source".into()), None).unwrap(); - let destination = mux.new_workspace(Some("destination".into()), None).unwrap(); - let destination_pane = mux.with_state(|state| state.pane_of(destination.id).unwrap()); - mux.new_tab(Some(destination_pane), None, None).unwrap(); - let terminal_id = source.terminal_public_id().cloned().unwrap(); - let destination_selectors = mux.ordinary_pane_selectors(destination_pane).unwrap(); - let destination_pane_id = destination_selectors.pane.clone().unwrap(); - let before = mux.with_state(|state| state.resource_revision); + let created = public_request( + &mux, + "create-terminal-workspace", + "workspace.create", + serde_json::json!({ + "machine":"current", + "session":"current", + "name":"close fence", + "initial_content":"terminal", + }), + Some("create-terminal-workspace"), + ); + let terminal = created["result"]["value"]["terminal_id"].clone(); + let cleanup_held_creation_fence = Arc::new(AtomicBool::new(false)); + mux.set_resource_close_cleanup_hook_for_test(Some(Arc::new({ + let cleanup_held_creation_fence = cleanup_held_creation_fence.clone(); + let mux = Arc::downgrade(&mux); + move || { + cleanup_held_creation_fence.store( + mux.upgrade() + .is_some_and(|mux| mux.resource_creation_execution.try_lock().is_err()), + Ordering::Release, + ); + } + }))); + public_request( + &mux, + "close-terminal", + "terminal.close", + serde_json::json!({ + "machine":"current", + "session":"current", + "terminal":terminal, + }), + Some("close-terminal"), + ); + assert!( + !cleanup_held_creation_fence.load(Ordering::Acquire), + "terminal cleanup ran while holding the global creation fence" + ); + } - mux.resource_project_terminal_selected( - crate::ResourceSelectors { - terminal: Some(terminal_id.to_string()), - ..Mux::ordinary_resource_selectors() - }, - destination_selectors, - 0, - Some("projected".into()), - None, - &WorkspaceMutation::local("projection-complete-delta"), + #[test] + fn persistent_mux_restart_restores_auxiliary_resources_and_exact_replay() { + let root = std::env::temp_dir().join(format!( + "cmux-resource-auxiliary-restart-{}", + WorkspacePublicId::random().unwrap() + )); + let session = "auxiliary-restart"; + let selectors = serde_json::json!({"machine":"current","session":"current"}); + let create_params = serde_json::json!({ + "machine":"current", + "session":"current", + "name":"Durable resources", + "initial_content":"terminal", + }); + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, ) .unwrap(); - let batches = mux.resource_events_after(before).unwrap().batches; - assert_eq!(batches.len(), 1); - let changes = batches[0].changes.as_array().unwrap(); - let snapshot = crate::resource_api::public_session_snapshot(&mux).unwrap(); - let terminal = snapshot["terminals"] - .as_array() - .unwrap() - .iter() - .find(|value| value["id"] == terminal_id.as_str()) - .unwrap(); - let terminal_delta = changes - .iter() - .find(|change| change["resource"] == "terminal" && change["id"] == terminal_id.as_str()) - .expect("projection changes the terminal's placement list"); - assert_eq!(&terminal_delta["value"], terminal); + let created = public_request( + &mux, + "create", + "workspace.create", + create_params.clone(), + Some("auxiliary-restart-create"), + ); + let created_value = created["result"]["value"].clone(); + let terminal_id = created_value["terminal_id"].as_str().unwrap().to_string(); + let terminal_public_id = TerminalPublicId::parse(&terminal_id).unwrap(); + let notification_params = serde_json::json!({ + "machine":"current", + "session":"current", + "title":"Durable build", + "body":"All checks passed", + "level":"warning", + "terminal_id":terminal_id, + }); + let notification = public_request( + &mux, + "notification", + "notification.create", + notification_params.clone(), + Some("auxiliary-restart-notification"), + ); + let notification_value = notification["result"]["value"].clone(); + let agent_params = serde_json::json!({ + "machine":"current", + "session":"current", + "terminal_id":terminal_id, + "state":"working", + "source":"hook", + "source_session":"worker-7", + }); + let agent = public_request( + &mux, + "agent", + "agent.report", + agent_params.clone(), + Some("auxiliary-restart-agent"), + ); + let agent_value = agent["result"]["value"].clone(); + let defaults_params = serde_json::json!({ + "machine":"current", + "session":"current", + "foreground":"#123456", + "background":"#654321", + "cursor_style":"bar", + "cursor_blink":true, + "palette":{"1":"#abcdef"}, + "complete":true, + }); + let defaults = public_request( + &mux, + "defaults", + "session.terminal_defaults.update", + defaults_params.clone(), + Some("auxiliary-restart-defaults"), + ); + let defaults_value = defaults["result"]["value"].clone(); + let projection_id = "projection_00000000000000000000000000000001"; + let projection_params = serde_json::json!({ + "machine":"current", + "session":"current", + "frontend_projection":projection_id, + "frontend_id":"cmux-test", + "window_id":"window-restart", + "generation":"launch-restart", + "projection":{ + "schema":"cmux.sidebar.test/1", + "revision":"7", + "rows":[{"label":"build","state":"working"}], + }, + }); + let projection = public_request( + &mux, + "projection", + "frontend_projection.put", + projection_params.clone(), + Some("auxiliary-restart-projection"), + ); + let projection_value = projection["result"]["value"].clone(); - let destination_tabs = snapshot["tabs"] - .as_array() - .unwrap() - .iter() - .filter(|tab| tab["pane_id"] == destination_pane_id) - .collect::>(); - assert_eq!(destination_tabs.len(), 3); - for tab in destination_tabs { - let tab_id = tab["id"].as_str().unwrap(); - let delta = changes - .iter() - .find(|change| change["resource"] == "tab" && change["id"] == tab_id) - .unwrap_or_else(|| panic!("projection omitted changed tab {tab_id}")); - assert_eq!(&delta["value"], tab); - } - } + let before = crate::resource_api::public_session_snapshot(&mux).unwrap(); + assert!(before["notifications"].as_array().unwrap().contains(¬ification_value)); + assert!(before["agents"].as_array().unwrap().contains(&agent_value)); + assert!(before["frontend_projections"].as_array().unwrap().contains(&projection_value)); + let durable_colors = mux.default_colors(); + assert_eq!(defaults_value["foreground"], "#123456"); + assert_eq!(defaults_value["background"], "#654321"); + assert_eq!(defaults_value["cursor_style"], "bar"); + assert_eq!(defaults_value["cursor_blink"], true); + assert_eq!(defaults_value["palette"]["1"], "#abcdef"); + let _ = mux.shutdown(); + drop(mux); - #[test] - fn selector_names_preserve_empty_whitespace_and_unicode_and_report_duplicates() { - let (mut restored, machine, session, _) = selector_fixture(); - let before = restored.state.resource_revision; - let mut selectors = routed_selectors(&machine, &session); - selectors.workspace = Some("Duplicate".into()); - let duplicate = resolve_resource_selectors( - &restored.state, - ResourceSelectorContext { - machine_id: &machine, - machine_name: None, - session_id: &session, - session_name: "test", - }, - crate::ResourceTarget::Workspace, - &selectors, + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let reopened = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, ) - .unwrap_err(); - assert_eq!(duplicate.code, "selector.ambiguous"); + .unwrap(); + assert_eq!(reopened.default_colors(), durable_colors); + let config_colors = DefaultColors { + fg: Some(crate::Rgb { r: 0xee, g: 0xdd, b: 0xcc }), + ..Default::default() + }; + reopened.seed_default_colors_if_no_durable_override(config_colors); assert_eq!( - duplicate.details["candidates"], - serde_json::json!([restore_workspace_id(1), restore_workspace_id(2)]) + reopened.default_colors(), + durable_colors, + "startup config must not overwrite an explicit durable update" ); - assert_eq!(restored.state.resource_revision, before); - for (index, name) in ["", " 日本語 "].into_iter().enumerate() { - restored.state.workspaces[index].name = name.into(); - selectors.workspace = Some(format!("name:{name}")); - let resolved = resolve_resource_selectors( - &restored.state, - ResourceSelectorContext { - machine_id: &machine, - machine_name: None, - session_id: &session, - session_name: "test", - }, - crate::ResourceTarget::Workspace, - &selectors, - ) - .unwrap(); - assert_eq!( - resolved.path.workspace, - Some(restored.state.workspaces[index].public_id.clone()) - ); + let after = crate::resource_api::public_session_snapshot(&reopened).unwrap(); + assert!(after["notifications"].as_array().unwrap().contains(¬ification_value)); + assert!(after["agents"].as_array().unwrap().contains(&agent_value)); + assert!(after["frontend_projections"].as_array().unwrap().contains(&projection_value)); + let restored_terminals = after["terminals"].as_array().unwrap(); + assert_eq!(restored_terminals.len(), 1); + let restored_terminal = &restored_terminals[0]; + assert_eq!(restored_terminal["id"], terminal_public_id.as_str()); + assert_eq!(restored_terminal["lifecycle"], "exited"); + assert_eq!(restored_terminal["running"], false); + assert_eq!(restored_terminal["tab_id"], Value::Null); + assert_eq!(restored_terminal["tab_ids"], serde_json::json!([])); + assert_eq!(restored_terminal["exit"]["outcome"]["kind"], "unknown"); + assert_eq!(restored_terminal["exit"]["outcome"]["reason"], "missing-host-record"); + assert_eq!(reopened.resource_surface_for_terminal(&terminal_public_id), None); + let exited = + reopened.wait_for_terminal_exit(&terminal_public_id, Some(Duration::ZERO)).unwrap(); + assert_eq!(exited["state"], "exited"); + assert_eq!(exited["outcome"]["kind"], "unknown"); + assert_eq!(exited["outcome"]["reason"], "missing-host-record"); + assert_eq!(restored_terminal["exit"]["outcome"], exited["outcome"]); + let notifications = public_request( + &reopened, + "notifications", + "notification.list", + selectors.clone(), + None, + ); + assert_eq!(notifications["result"], serde_json::json!([notification_value])); + let agents = public_request(&reopened, "agents", "agent.list", selectors.clone(), None); + assert_eq!(agents["result"], serde_json::json!([agent_value])); + let snapshot = public_request(&reopened, "snapshot", "session.snapshot", selectors, None); + assert_eq!(snapshot["result"]["notifications"], after["notifications"]); + assert_eq!(snapshot["result"]["agents"], after["agents"]); + assert_eq!(snapshot["result"]["frontend_projections"], after["frontend_projections"]); + + for (id, operation, params, key, original) in [ + ( + "create-replay", + "workspace.create", + create_params, + "auxiliary-restart-create", + created_value, + ), + ( + "notification-replay", + "notification.create", + notification_params, + "auxiliary-restart-notification", + notification_value, + ), + ("agent-replay", "agent.report", agent_params, "auxiliary-restart-agent", agent_value), + ( + "defaults-replay", + "session.terminal_defaults.update", + defaults_params, + "auxiliary-restart-defaults", + defaults_value, + ), + ( + "projection-replay", + "frontend_projection.put", + projection_params, + "auxiliary-restart-projection", + projection_value, + ), + ] { + let replay = public_request(&reopened, id, operation, params, Some(key)); + assert_eq!(replay["result"]["value"], original, "{operation}"); + assert_eq!(replay["result"]["replayed"], true, "{operation}"); } - } - - #[test] - fn selector_rejects_incomplete_name_chain_wrong_type_stale_id_and_wrong_parent() { - let (restored, machine, session, topology) = selector_fixture(); - let context = ResourceSelectorContext { - machine_id: &machine, - machine_name: None, - session_id: &session, - session_name: "test", - }; - - let mut incomplete = routed_selectors(&machine, &session); - incomplete.screen = Some(topology.screens[0].public_id.to_string()); - incomplete.pane = Some("one".into()); - let error = resolve_resource_selectors( - &restored.state, - context.clone(), - crate::ResourceTarget::Pane, - &incomplete, - ) - .unwrap_err(); - assert_eq!(error.code, "selector.invalid"); - assert_eq!(error.details["scope"], "pane"); + assert_eq!(reopened.resource_notifications(256).len(), 1); assert!( - error.details["reason"].as_str().is_some_and(|reason| reason.contains("workspace")) + reopened.list_agents(None, None).is_empty(), + "the legacy live-surface cache must not retain a detached terminal" ); - - let mut wrong_type = routed_selectors(&machine, &session); - wrong_type.workspace = Some(topology.browsers[0].public_id.to_string()); assert_eq!( - resolve_resource_selectors( - &restored.state, - context.clone(), - crate::ResourceTarget::Workspace, - &wrong_type, - ) - .unwrap_err() - .code, - "selector.invalid" + reopened + .workspace_registry + .lock() + .unwrap() + .public_frontend_projections() + .unwrap() + .len(), + 1 ); - let mut stale = routed_selectors(&machine, &session); - stale.workspace = Some(WorkspacePublicId::random().unwrap().to_string()); - assert_eq!( - resolve_resource_selectors( - &restored.state, - context.clone(), - crate::ResourceTarget::Workspace, - &stale, - ) - .unwrap_err() - .code, - "selector.not_found" - ); + let _ = reopened.shutdown(); + drop(reopened); - let mut wrong_parent = routed_selectors(&machine, &session); - wrong_parent.workspace = Some(restore_workspace_id(2).to_string()); - wrong_parent.browser = Some(topology.browsers[0].public_id.to_string()); - let error = resolve_resource_selectors( - &restored.state, - context, - crate::ResourceTarget::Browser, - &wrong_parent, + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + registry.insert_corrupt_terminal_defaults_for_test(); + let error = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, ) - .unwrap_err(); - assert_eq!(error.code, "selector.wrong_parent"); - assert!(error.details["expected_parent"].as_str().unwrap().starts_with("ws_")); - assert!(error.details["actual_parent"].as_str().unwrap().starts_with("ws_")); - let encoded = serde_json::to_string(&error).unwrap(); - for private in ["workspace_key", "surface", "numeric_id", "short_id", "\"slot\""] { - assert!(!encoded.contains(private)); - } + .err() + .expect("corrupt durable terminal defaults must fail startup"); + assert!( + error.to_string().contains("omitted background"), + "unexpected startup error: {error:#}" + ); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn selector_rename_and_concurrent_rename_share_one_locked_snapshot() { - let mux = test_mux(); - let first = mux - .resource_create_empty_workspace( - Some("target".into()), - None, - Some(0), - &WorkspaceMutation::new("selector-race-first", "test").unwrap(), - ) - .unwrap(); - let second = mux - .resource_create_empty_workspace( - Some("other".into()), - None, - Some(1), - &WorkspaceMutation::new("selector-race-second", "test").unwrap(), + fn resource_layout_undo_confirmation_is_restart_safe() { + let root = std::env::temp_dir() + .join(format!("cmux-resource-undo-restart-{}", WorkspacePublicId::random().unwrap())); + let (fixture_snapshot, fixture_topology) = resource_restore_fixture(); + { + let mut registry = WorkspaceRegistry::open(&root, "undo-restart").unwrap(); + registry + .commit_resource_patch( + &WorkspaceMutation::new("seed-undo-restart", "test").unwrap(), + "session.restore_fixture", + &serde_json::json!({"fixture":"layout-undo-restart"}), + None, + Some(0), + &resource_restore_patch(&fixture_snapshot, &fixture_topology), + &serde_json::json!({"restored":true}), + &serde_json::json!([{"event":"session.restored"}]), + ) + .unwrap(); + } + + let registry = WorkspaceRegistry::open(&root, "undo-restart").unwrap(); + let mux = Mux::from_workspace_registry( + "undo-restart".into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let base_pane = mux.with_state(|state| state.workspaces[0].screens[0].active_pane); + let created_terminal = mux.new_pane_right(base_pane, 0.5, Some((38, 22))).unwrap(); + let created_pane = mux.with_state(|state| state.pane_of(created_terminal.id).unwrap()); + let created = mux + .new_browser_tab( + "about:blank#layout-undo-restart".into(), + Some(created_pane), + Some((38, 22)), ) .unwrap(); - let first_id = - WorkspacePublicId::parse(first.result["workspace"].as_str().unwrap()).unwrap(); - let second_id = - WorkspacePublicId::parse(second.result["workspace"].as_str().unwrap()).unwrap(); - let (machine, session) = { + assert!(mux.close_surface(created_terminal.id).unwrap()); + drop(created_terminal); + let (selectors, screen_id) = { let registry = mux.workspace_registry.lock().unwrap(); - (registry.machine_id().clone(), registry.session_id().clone()) - }; - let mut selectors = routed_selectors(&machine, &session); - selectors.workspace = Some("target".into()); - - let (resolved_tx, resolved_rx) = std::sync::mpsc::sync_channel(1); - let overlap = Arc::new(std::sync::Barrier::new(2)); - *mux.resource_rename_after_selector_resolution.lock().unwrap() = Some(Arc::new({ - let overlap = overlap.clone(); - move |resolved| { - resolved_tx.send(resolved.clone()).unwrap(); - overlap.wait(); - } - })); - let selected = { - let mux = mux.clone(); - std::thread::spawn(move || { - mux.resource_rename_workspace_selected( - selectors, - "renamed".into(), - None, - Some(2), - &WorkspaceMutation::new("selector-race-selected", "test").unwrap(), - ) - }) - }; - assert_eq!(resolved_rx.recv().unwrap(), first_id); - let concurrent = { - let mux = mux.clone(); - let second_id = second_id.clone(); - std::thread::spawn(move || { - overlap.wait(); - mux.resource_rename_workspace( - &second_id, - "target".into(), - None, - None, - &WorkspaceMutation::new("selector-race-direct", "test").unwrap(), - ) - }) + let state = mux.state.lock().unwrap(); + let (workspace, screen) = state.screen_of(created_pane).unwrap(); + ( + crate::ResourceSelectors { + machine: Some(registry.machine_id().to_string()), + session: Some(registry.session_id().to_string()), + workspace: Some(state.workspaces[workspace].public_id.to_string()), + screen: Some(state.workspaces[workspace].screens[screen].public_id.to_string()), + ..crate::ResourceSelectors::default() + }, + state.workspaces[workspace].screens[screen].public_id.clone(), + ) }; - selected.join().unwrap().unwrap(); - concurrent.join().unwrap().unwrap(); - *mux.resource_rename_after_selector_resolution.lock().unwrap() = None; + let preview_fields = + serde_json::json!({"confirm_close":false}).as_object().unwrap().clone(); + let durable_before = + mux.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); + let preview_error = mux + .resource_topology_operation( + ResourceOperation::ScreenLayoutUndo, + selectors.clone(), + preview_fields, + Some(durable_before.revision), + &WorkspaceMutation::new("restart-undo-preview", "test").unwrap(), + ) + .unwrap_err(); + let preview_revision = preview_error + .downcast_ref::() + .and_then(|error| error.details["revision"].as_str()) + .and_then(|revision| revision.parse::().ok()) + .unwrap(); + let confirmation_token = preview_error + .downcast_ref::() + .and_then(|error| error.details["confirmation_token"].as_str()) + .unwrap() + .to_string(); + assert_eq!(preview_revision, durable_before.revision); + drop(created); + let _ = mux.shutdown(); + let shutdown_deadline = Instant::now() + Duration::from_secs(10); + while Arc::strong_count(&mux) > 1 && Instant::now() < shutdown_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + Arc::strong_count(&mux), + 1, + "restored browser bootstrap workers must release the mux before restart" + ); + drop(mux); - mux.with_state(|state| { - let first = state.resource_indexes.workspaces[&first_id]; - let second = state.resource_indexes.workspaces[&second_id]; - assert_eq!(state.workspaces[state.workspace_index(first).unwrap()].name, "renamed"); - assert_eq!(state.workspaces[state.workspace_index(second).unwrap()].name, "target"); + let registry = WorkspaceRegistry::open(&root, "undo-restart").unwrap(); + let reopened = Mux::from_workspace_registry( + "undo-restart".into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let reopened_before = + reopened.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); + // Restart may reconcile terminal liveness into a later public + // revision. The confirmation token is fenced by generation, screen, + // layout revision, and pane/tab membership rather than that unrelated + // liveness revision. + assert!(reopened_before.revision >= durable_before.revision); + assert_eq!(reopened_before.screens, durable_before.screens); + assert_eq!(reopened_before.panes, durable_before.panes); + assert_eq!(reopened_before.tabs, durable_before.tabs); + assert!(reopened_before.screens.iter().any(|screen| screen.public_id == screen_id)); + let state_before = reopened.with_state(state_topology_fingerprint); + let confirm_fields = serde_json::json!({ + "confirm_close":true, + "confirmation_token":confirmation_token, + }) + .as_object() + .unwrap() + .clone(); + let confirm_fingerprint = serde_json::json!({ + "operation":"screen.layout.undo", + "selectors":selectors, + "fields":confirm_fields, }); - } + let confirm_mutation = WorkspaceMutation::new("restart-undo-confirm", "test").unwrap(); - #[test] - fn resource_startup_restores_nested_columns_selections_and_empty_workspace() { - let (snapshot, topology) = resource_restore_fixture(); - let expected_tab = topology.tabs[5].public_id.clone(); - let expected_browser = topology.tabs[5].content_id.clone(); - let expected_base_column = topology.screens[0].viewport.columns[0].id.clone(); - let mut restored = restore_resource_state(snapshot, topology).unwrap(); - Mux::rebuild_split_screen_index(&mut restored.state); + let error = reopened + .resource_topology_operation( + ResourceOperation::ScreenLayoutUndo, + selectors, + confirm_fields, + Some(reopened_before.revision), + &confirm_mutation, + ) + .unwrap_err(); - let state = &restored.state; - assert_eq!(state.workspaces.len(), 2); - assert_eq!(state.active_workspace, 1); - assert!(state.workspaces[1].screens.is_empty()); - assert_eq!(state.workspaces[0].active_screen, 1); - let columns = &state.workspaces[0].screens[0]; - assert_eq!(columns.layout_columns.len(), 2); - assert_eq!(columns.viewport_base_width, Some(0.8)); - assert_eq!(columns.zoomed_pane, Some(columns.active_pane)); assert!(matches!( - &columns.layout_columns[0].root, - Node::Split { a, .. } - if matches!(a.as_ref(), Node::Stack { expanded, .. } - if *expanded == state.resource_indexes.panes[&restore_pane_id(2)]) + error.downcast_ref::(), + Some(LayoutUndoError::Unavailable) )); - assert!(columns.layout_column_projection_is_consistent()); - assert!(state.resource_indexes.splits.contains_key(&expected_base_column)); - let selected_pane = state.resource_indexes.panes[&restore_pane_id(5)]; - assert_eq!( - state.panes[&selected_pane].tabs[state.panes[&selected_pane].active_tab], - state.resource_indexes.tabs[&expected_tab] + assert_eq!(reopened.with_state(state_topology_fingerprint), state_before); + let registry = reopened.workspace_registry.lock().unwrap(); + assert_eq!(registry.resource_topology_snapshot().unwrap(), reopened_before); + assert!( + registry.resource_events_after(reopened_before.revision).unwrap().batches.is_empty() ); - assert_eq!( - state.resource_indexes.content_placements[&expected_browser][0], - state.resource_indexes.tabs[&expected_tab] + assert!( + registry + .lookup_resource_effect( + &confirm_mutation.id, + "screen.layout.undo", + &confirm_fingerprint, + ) + .unwrap() + .is_none() ); - assert_eq!(state.surfaces.len(), 0); - assert_eq!(restored.contents.len(), 6); - assert!(restored.next_id > 100); + drop(registry); + let _ = reopened.shutdown(); + drop(reopened); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + fn insert_running_terminal_identity_surface( + mux: &Arc, + terminal_id: &str, + incarnation: &str, + workspace_key: &str, + ) -> Arc { + let surface = + mux.seed_running_terminal_for_test(terminal_id, incarnation, workspace_key).unwrap(); + mux.surface(surface).unwrap() } #[test] - fn restored_terminal_runtime_materializes_all_durable_views_and_survives_zero_views() { - let (snapshot, mut topology) = resource_restore_fixture(); - let terminal_id = restore_terminal_id(50); - let host_id = "00000000000040008000000000000050"; - let replaced_browsers = - topology.tabs[..2].iter().map(|tab| tab.content_id.clone()).collect::>(); - topology.browsers.retain(|browser| { - !replaced_browsers.contains(&ContentPublicId::Browser(browser.public_id.clone())) - }); - for tab in &mut topology.tabs[..2] { - tab.content_id = ContentPublicId::Terminal(terminal_id.clone()); - tab.browser_url = None; - tab.terminal_id = Some(host_id.into()); - } - let mut restored = restore_resource_state(snapshot, topology.clone()).unwrap(); - let content_id = ContentPublicId::Terminal(terminal_id.clone()); - let placements = restored.state.placements_of_content(&content_id).to_vec(); - assert_eq!(placements.len(), 2); - let first_tab = topology.tabs[0].public_id.clone(); - let source = Surface::spawn_for_test_with_resource_identity( - placements[0], - SurfaceOptions::default(), - Weak::new(), - Some(TabResourceIdentity::new(first_tab, content_id.clone())), + fn terminal_registry_launch_spec_never_persists_environment_secrets() { + let sentinel = "cmux-secret-sentinel-do-not-persist"; + let options = SurfaceOptions { + command: Some(vec!["/bin/sh".into(), "-c".into(), sentinel.into()]), + cwd: Some(format!("/tmp/{sentinel}")), + extra_env: vec![ + ("API_BEARER_TOKEN".into(), sentinel.into()), + ("CMUX_TUI_SOCKET".into(), "/tmp/cmux.sock".into()), + ("CMUX_TUI_HOOK".into(), "/tmp/cmux-tui-hook".into()), + ], + ..SurfaceOptions::default() + }; + let encoded = serde_json::to_string(&terminal_launch_spec(&options)).unwrap(); + assert!(!encoded.contains(sentinel)); + assert!(!encoded.contains("API_BEARER_TOKEN")); + assert!(!encoded.contains("/tmp/cmux.sock")); + assert!(!encoded.contains("/tmp/cmux-tui-hook")); + assert!(!encoded.contains("/bin/sh")); + assert!(encoded.contains("CMUX_TUI_SOCKET")); + assert!(encoded.contains("CMUX_TUI_HOOK")); + } + + #[test] + fn terminal_create_mutation_persists_only_a_secret_free_digest() { + const TERMINAL: &str = "00000000000040008000000000000009"; + let sentinel = "cmux-create-secret-sentinel-do-not-persist"; + let root = std::env::temp_dir() + .join(format!("cmux-create-fingerprint-{}", crate::workspace_registry::new_uuid_v4())); + let argv = vec!["/bin/sh".to_string(), "-c".to_string(), sentinel.to_string()]; + let cwd = format!("/tmp/{sentinel}"); + let name = format!("terminal-{sentinel}"); + let fingerprint = terminal_create_fingerprint( + "workspace-one", + Some(TERMINAL), + Some(&argv), + Some(&cwd), + Some(&name), + Some((80, 24)), ) .unwrap(); + assert!(!serde_json::to_string(&fingerprint).unwrap().contains(sentinel)); - insert_restored_terminal_runtime_checked(&mut restored.state, source.clone()).unwrap(); - assert_eq!(restored.state.terminal_catalog.len(), 1); - for placement in &placements { - assert!( - restored.state.surfaces[placement].shares_terminal_runtime(&source), - "every durable tab must project the one restored terminal runtime" - ); + { + let mut registry = WorkspaceRegistry::open(&root, "secret-test").unwrap(); + registry + .commit( + &WorkspaceMutation::new("workspace", "test").unwrap(), + &serde_json::json!({"op":"create-workspace"}), + None, + Some(0), + "workspace-added", + "workspace-one", + &[RegistryWorkspace { + id: 1, + public_id: WorkspacePublicId::random().unwrap(), + key: "workspace-one".into(), + name: "One".into(), + group_key: "secret-test".into(), + }], + &serde_json::json!({"workspace":1,"key":"workspace-one"}), + ) + .unwrap(); + registry + .commit_terminal( + &WorkspaceMutation::new("create", "browser").unwrap(), + &fingerprint, + None, + Some(0), + "terminal-reserved", + &RegistryTerminal { + terminal_id: TERMINAL.into(), + workspace_key: "workspace-one".into(), + incarnation: None, + lifecycle: TerminalLifecycle::Launching, + launch_spec: serde_json::json!({"command_present":true}), + exit: None, + }, + &serde_json::json!({"terminal_id":TERMINAL}), + ) + .unwrap(); } - let mux = test_mux(); - for placement in placements { - let _ = remove_surface(&mux, &mut restored.state, placement); + fn assert_tree_does_not_contain(path: &Path, needle: &[u8]) { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + assert_tree_does_not_contain(&path, needle); + } else { + let bytes = std::fs::read(&path).unwrap(); + assert!( + !bytes.windows(needle.len()).any(|window| window == needle), + "secret persisted in {}", + path.display() + ); + } + } } - assert!(restored.state.surfaces.is_empty()); - assert!(restored.state.placements_of_content(&content_id).is_empty()); - assert!( - restored - .state - .terminal_catalog - .get(&terminal_id) - .is_some_and(|catalogued| catalogued.shares_terminal_runtime(&source)), - "removing the final view must retain the restored runtime owner" - ); + assert_tree_does_not_contain(&root, sentinel.as_bytes()); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn resource_startup_rejects_corrupt_persisted_selectors() { - let (snapshot, mut topology) = resource_restore_fixture(); - topology.active_screens[0].1 = Some(restore_screen_id(999)); - assert!( - restore_resource_state(snapshot.clone(), topology) - .err() - .unwrap() - .to_string() - .contains("unknown active screen") - ); + fn stable_terminal_lookup_never_chooses_between_duplicate_ids() { + let terminal_id = "00112233445566778899aabbccddeeff"; + let identities = vec![ + ( + 10, + TerminalHostIdentity { + terminal_id: terminal_id.into(), + incarnation: "11111111111111111111111111111111".into(), + }, + ), + ( + 20, + TerminalHostIdentity { + terminal_id: terminal_id.into(), + incarnation: "22222222222222222222222222222222".into(), + }, + ), + ]; - let (_, mut topology) = resource_restore_fixture(); - topology.panes[0].active_tab = Some(restore_tab_id(999)); - assert!( - restore_resource_state(snapshot, topology) - .err() - .unwrap() - .to_string() - .contains("unknown active tab") + assert_eq!( + unique_terminal_match(terminal_id, identities.clone()).unwrap_err().to_string(), + "duplicate_terminal_id" ); + let unique = + unique_terminal_match(terminal_id, identities.into_iter().take(1)).unwrap().unwrap(); + assert_eq!(unique.0, 10); } #[test] - fn resource_startup_requires_exact_browser_restart_metadata_coverage() { - let (snapshot, mut topology) = resource_restore_fixture(); - topology.browsers.pop(); - assert!( - restore_resource_state(snapshot.clone(), topology) - .err() - .unwrap() - .to_string() - .contains("has no restart metadata") - ); + fn cell_pixel_metric_publishes_only_after_existing_surface_fanout() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let observed_at_publish = Arc::new(Mutex::new(Vec::new())); + *mux.cell_pixel_before_publish.lock().unwrap() = Some(Arc::new({ + let observed_at_publish = observed_at_publish.clone(); + move |metric| observed_at_publish.lock().unwrap().push(metric) + })); - let (_, mut topology) = resource_restore_fixture(); - let mut orphan = topology.browsers[0].clone(); - orphan.public_id = restore_browser_id(999); - topology.browsers.push(orphan); - assert!( - restore_resource_state(snapshot.clone(), topology) - .err() - .unwrap() - .to_string() - .contains("orphan browser metadata") - ); + let update = mux.set_cell_pixel_size(9, 18); - let (_, mut topology) = resource_restore_fixture(); - topology.browsers.push(topology.browsers[0].clone()); - assert!( - restore_resource_state(snapshot, topology) - .err() - .unwrap() - .to_string() - .contains("duplicate browser metadata") - ); + assert_eq!(*observed_at_publish.lock().unwrap(), vec![(8, 16)]); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + assert_eq!(update.resizes, vec![(surface.id, (80, 24), 0)]); + assert!(update.failures.is_empty()); } #[test] - fn resource_startup_rejects_missing_required_active_selectors() { - let (snapshot, mut topology) = resource_restore_fixture(); - topology.panes[0].active_tab = None; - assert_eq!( - restore_resource_state(snapshot.clone(), topology).err().unwrap().to_string(), - format!("pane {} has tabs but no active tab", restore_pane_id(1)) - ); - - let (_, mut topology) = resource_restore_fixture(); - topology.active_screens[0].1 = None; - assert_eq!( - restore_resource_state(snapshot.clone(), topology).err().unwrap().to_string(), - format!("workspace {} has screens but no active screen", restore_workspace_id(1)) - ); - - let (_, mut topology) = resource_restore_fixture(); - topology.active_workspace = None; - assert_eq!( - restore_resource_state(snapshot.clone(), topology).err().unwrap().to_string(), - "session has workspaces but no active workspace" - ); + fn unchanged_cell_pixel_result_must_match_the_requested_metric() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new(|_, _, _| Ok(None))); - let (_, mut topology) = resource_restore_fixture(); - topology.active_screens.pop(); - assert_eq!( - restore_resource_state(snapshot, topology).err().unwrap().to_string(), - "active-screen metadata does not exactly cover the live workspaces" - ); - } + let update = mux.set_cell_pixel_size(9, 18); - #[test] - fn resource_startup_accepts_none_only_for_empty_containers() { - let (snapshot, mut topology) = resource_restore_fixture(); - let empty_pane = topology.panes[0].public_id.clone(); - topology.tabs.retain(|tab| tab.pane_id != empty_pane); - let retained_browsers = topology - .tabs - .iter() - .filter_map(|tab| match &tab.content_id { - ContentPublicId::Browser(browser) => Some(browser.clone()), - ContentPublicId::Terminal(_) => None, - }) - .collect::>(); - topology.browsers.retain(|browser| retained_browsers.contains(&browser.public_id)); - topology.panes[0].active_tab = None; - let restored = restore_resource_state(snapshot, topology).unwrap(); + assert!(update.resizes.is_empty()); + assert_eq!(update.failures.len(), 1); + assert_eq!(update.failures[0].surface, surface.id); assert!( - restored.state.panes[&restored.state.resource_indexes.panes[&empty_pane]] - .tabs - .is_empty() + update.failures[0].error.contains("did not converge"), + "unexpected failure: {}", + update.failures[0].error ); - - let (mut snapshot, mut topology) = resource_restore_fixture(); - snapshot.workspaces.clear(); - topology.active_workspace = None; - topology.active_screens.clear(); - topology.screens.clear(); - topology.panes.clear(); - topology.tabs.clear(); - topology.browsers.clear(); - assert!(restore_resource_state(snapshot, topology).unwrap().state.workspaces.is_empty()); + assert_eq!(mux.cell_pixel_size(), (8, 16)); + assert_eq!(surface.test_cell_pixel_size(), (8, 16)); } #[test] - fn resource_state_patch_commits_before_infallible_projection_and_replays_once() { + fn terminal_spawn_releases_cell_pixel_lifecycle_and_reconciles_before_publish() { let mux = test_mux(); - let mutation = WorkspaceMutation::new("create-once", "test-client").unwrap(); - let first = mux - .resource_create_empty_workspace(Some("API".into()), None, Some(0), &mutation) - .unwrap(); - assert_eq!(first.revision, 1); - assert!(!first.replayed); - let public_id = - WorkspacePublicId::parse(first.result["workspace"].as_str().unwrap()).unwrap(); - mux.with_state(|state| { - assert_eq!(state.resource_revision, 1); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].public_id, public_id); - assert_eq!(state.workspaces[0].name, "API"); - }); - let durable = mux.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); - assert_eq!(durable.revision, 1); - assert_eq!(durable.active_workspace, Some(public_id)); + let observed_unlocked = Arc::new(Mutex::new(Vec::new())); + *mux.terminal_spawn_after_cell_pixel_snapshot.lock().unwrap() = Some(Arc::new({ + let mux = Arc::downgrade(&mux); + let observed_unlocked = observed_unlocked.clone(); + move |unlocked| { + observed_unlocked.lock().unwrap().push(unlocked); + if unlocked { + mux.upgrade().unwrap().set_cell_pixel_size(9, 18); + } + } + })); - let replay = mux - .resource_create_empty_workspace(Some("API".into()), None, Some(0), &mutation) - .unwrap(); - assert!(replay.replayed); - assert_eq!(replay.revision, 1); - assert_eq!(replay.result, first.result); - mux.with_state(|state| { - assert_eq!(state.resource_revision, 1); - assert_eq!(state.workspaces.len(), 1); - }); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + + assert_eq!(*observed_unlocked.lock().unwrap(), vec![true]); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(surface.test_cell_pixel_size(), (9, 18)); } #[test] - fn resource_state_patch_failure_leaves_memory_and_database_unchanged() { + fn kitty_image_storage_and_copied_pixels_share_one_process_budget() { let mux = test_mux(); - mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); - let error = mux - .resource_create_empty_workspace( - Some("Never visible".into()), - None, - Some(0), - &WorkspaceMutation::new("fail-create", "test-client").unwrap(), - ) - .unwrap_err(); - assert!(error.to_string().contains("forced resource patch failure")); - mux.with_state(|state| { - assert!(state.workspaces.is_empty()); - assert_eq!(state.resource_revision, 0); - }); - let registry = mux.workspace_registry.lock().unwrap(); - assert_eq!(registry.resource_topology_snapshot().unwrap().revision, 0); - assert!(registry.snapshot().unwrap().workspaces.is_empty()); - registry.set_resource_patch_failure(false).unwrap(); - } + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); - fn begin_test_resource_effect(mux: &Mux, idempotency_key: &str, operation: &str) -> Value { - let fingerprint = serde_json::json!({ - "operation": operation, - "fixture": idempotency_key, - }); - let expected_revision = mux.with_state(|state| state.resource_revision); - assert!(matches!( - mux.prepare_resource_effect( - idempotency_key, - operation, - &fingerprint, - &serde_json::json!({}), - None, - Some(expected_revision), - ) - .unwrap(), - ResourceEffectPreparation::Execute { resumed: false, .. } - )); - mux.mark_resource_effect_executing(idempotency_key, operation, &fingerprint).unwrap(); - fingerprint + let configured = surfaces + .iter() + .map(|surface| { + surface.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap() + }) + .map(|limits| { + limits + .image_bytes + .saturating_mul(KITTY_IMAGE_PERSISTENT_COPIES_PER_SURFACE) + .saturating_add(limits.inflight_bytes) + }) + .sum::(); + + assert!( + configured <= KITTY_IMAGE_PROCESS_BUDGET_BYTES, + "per-terminal limits allow {configured} bytes across native screen storage, copied caches, and in-flight uploads" + ); } #[test] - fn receipt_only_effect_commit_wakes_journal_subscribers() { - let mux = test_mux(); - let fingerprint = begin_test_resource_effect(&mux, "receipt-only-effect", "terminal.input"); - let before = mux.journal_event_epoch(); - mux.commit_resource_effect( - "receipt-only-effect", - "terminal.input", - &fingerprint, - &ResourceEffectOutcome::Success(serde_json::json!({})), - None, - ) - .unwrap(); - assert_eq!(mux.journal_event_epoch(), before + 1); + fn kitty_capacity_buckets_reserve_encoded_upload_bytes_inside_the_process_budget() { + let mut capacity = 1; + while capacity <= KITTY_IMAGE_BUDGET_OWNER_LIMIT { + let limits = kitty_image_limits_for_capacity(capacity); + assert_eq!( + limits.inflight_bytes, + ghostty_vt::kitty_inflight_replay_limit_for_image_bytes(limits.image_bytes) + ); + assert!( + kitty_surface_byte_reservation(limits.image_bytes).saturating_mul(capacity as u64) + <= KITTY_IMAGE_PROCESS_BUDGET_BYTES, + "capacity {capacity} exceeded the process byte budget with {limits:?}" + ); + capacity = capacity.saturating_mul(2); + } } + #[cfg(unix)] #[test] - fn projected_effect_failure_rolls_back_revision_event_and_topology() { + fn exited_terminal_placeholders_do_not_consume_kitty_quota() { let mux = test_mux(); - let surface = - mux.new_browser_tab("about:blank#rollback".into(), None, Some((80, 24))).unwrap(); - let before_revision = mux.with_state(|state| state.resource_revision); - let fingerprint = - begin_test_resource_effect(&mux, "effect-projection-rollback", "test.project"); - let before_epoch = mux.resource_event_epoch(); - mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); + let opts = mux.surface_options.lock().unwrap().clone(); + let live = + Surface::spawn_for_test(mux.next_id(), opts.clone(), Arc::downgrade(&mux)).unwrap(); + wait_for_kitty_image_budget(&mux); + let unconstrained = + live.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(); - let error = mux - .commit_full_resource_effect_projection( - "effect-projection-rollback", - "test.project", - &fingerprint, - serde_json::json!({"projected":true}), - ) - .unwrap_err(); + let placeholder = Surface::exited_terminal_placeholder( + mux.next_id(), + opts, + Arc::downgrade(&mux), + TerminalHostIdentity { + terminal_id: "00112233445566778899aabbccddeeff".into(), + incarnation: "11111111111111111111111111111111".into(), + }, + ) + .unwrap(); + wait_for_kitty_image_budget(&mux); - assert!(error.to_string().contains("forced resource patch failure")); - assert_eq!(mux.resource_event_epoch(), before_epoch); - mux.with_state(|state| assert_eq!(state.resource_revision, before_revision)); - let registry = mux.workspace_registry.lock().unwrap(); - assert_eq!(registry.resource_topology_snapshot().unwrap().revision, before_revision); - assert!(registry.resource_events_after(before_revision).unwrap().batches.is_empty()); - registry.set_resource_patch_failure(false).unwrap(); - surface.kill(); + assert_eq!( + live.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(), + unconstrained, + "an exited placeholder reduced a live terminal's graphics share" + ); + assert_eq!( + placeholder + .with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()) + .unwrap(), + KittyGraphicsLimits::disabled(), + "an exited placeholder retained graphics resources it cannot use" + ); } #[test] - fn projected_effect_holds_writer_fence_through_commit_and_publishes_once() { - use std::sync::mpsc; - + fn kitty_object_limits_cover_primary_and_alternate_screens() { let mux = test_mux(); - let surface = mux.new_browser_tab("about:blank#race".into(), None, Some((80, 24))).unwrap(); - let original_name = mux.with_state(|state| state.workspaces[0].name.clone()); - let before_revision = mux.with_state(|state| state.resource_revision); - let before_epoch = mux.resource_event_epoch(); - let fingerprint = - begin_test_resource_effect(&mux, "effect-projection-race", "test.project"); - let projection_ready = Arc::new(std::sync::Barrier::new(2)); - let allow_commit = Arc::new(std::sync::Barrier::new(2)); - *mux.resource_projection_before_commit.lock().unwrap() = Some(Arc::new({ - let projection_ready = projection_ready.clone(); - let allow_commit = allow_commit.clone(); - move || { - projection_ready.wait(); - allow_commit.wait(); - } - })); - - let commit_thread = { - let mux = mux.clone(); - std::thread::spawn(move || { - mux.commit_full_resource_effect_projection( - "effect-projection-race", - "test.project", - &fingerprint, - serde_json::json!({"projected":true}), - ) - .unwrap() - }) - }; - projection_ready.wait(); - - let (acquired_tx, acquired_rx) = mpsc::channel(); - let racing_writer = { - let mux = mux.clone(); - std::thread::spawn(move || { - let mut state = mux.state.lock().unwrap(); - state.workspaces[0].name = "Raced after capture".into(); - acquired_tx.send(()).unwrap(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); + let (configured_images, configured_placements) = surfaces + .iter() + .map(|surface| { + surface + .with_terminal(|terminal| { + let primary_images = terminal.kitty_image_count_limit().unwrap(); + let primary_placements = terminal.kitty_placement_count_limit().unwrap(); + terminal.vt_write(b"\x1b[?1049h"); + let alternate_images = terminal.kitty_image_count_limit().unwrap(); + let alternate_placements = terminal.kitty_placement_count_limit().unwrap(); + terminal.vt_write(b"\x1b[?1049l"); + ( + primary_images.saturating_add(alternate_images), + primary_placements.saturating_add(alternate_placements), + ) + }) + .unwrap() }) - }; + .fold((0u64, 0u64), |(images, placements), candidate| { + (images.saturating_add(candidate.0), placements.saturating_add(candidate.1)) + }); assert!( - acquired_rx.recv_timeout(Duration::from_millis(50)).is_err(), - "a topology writer entered between projection capture and durable commit" + configured_images <= 4_096, + "primary and alternate screens allow {configured_images} native image records process-wide" + ); + assert!( + configured_placements <= 16_384, + "primary and alternate screens allow {configured_placements} native placements process-wide" ); - allow_commit.wait(); - let commit = commit_thread.join().unwrap(); - acquired_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - racing_writer.join().unwrap(); - *mux.resource_projection_before_commit.lock().unwrap() = None; - - assert_eq!(commit.revision, before_revision + 1); - assert_eq!(mux.resource_event_epoch(), before_epoch + 1); - let registry = mux.workspace_registry.lock().unwrap(); - let snapshot = registry.snapshot().unwrap(); - assert_eq!(snapshot.resource_revision, before_revision + 1); - assert_eq!(snapshot.workspaces[0].name, original_name); - let events = registry.resource_events_after(before_revision).unwrap(); - assert_eq!(events.batches.len(), 1); - let changes = events.batches[0].changes.as_array().unwrap(); - assert!(!changes.is_empty()); - for (sequence, change) in changes.iter().enumerate() { - assert_eq!(change["sequence"], sequence); - assert!(matches!(change["kind"].as_str(), Some("upsert" | "delete"))); - assert!(change["resource"].is_string()); - assert!(change["id"].is_string()); - assert!(change.get("event").is_none()); - } - surface.kill(); } #[test] - fn ordinary_workspace_mutations_publish_one_immediate_public_revision() { - fn assert_public_state( - mux: &Mux, - revision: u64, - expected: &[(&str, bool)], - ) -> Vec { - assert_eq!(mux.resource_event_epoch(), revision); - mux.with_state(|state| assert_eq!(state.resource_revision, revision)); - - let snapshot = crate::resource_api::public_session_snapshot(mux).unwrap(); - assert_eq!(snapshot["session"]["revision"], revision.to_string()); - let workspaces = snapshot["workspaces"].as_array().unwrap(); - assert_eq!(workspaces.len(), expected.len()); - for (index, (workspace, (name, focused))) in - workspaces.iter().zip(expected.iter()).enumerate() - { - assert_eq!(workspace["name"], *name); - assert_eq!(workspace["index"], u64::try_from(index).unwrap()); - assert_eq!(workspace["focused"], *focused); - } - - let registry = mux.workspace_registry.lock().unwrap(); - assert_eq!(registry.snapshot().unwrap().resource_revision, revision); - let events = registry.resource_events_after(0).unwrap(); - assert_eq!(events.batches.len(), usize::try_from(revision).unwrap()); - let batch = events.batches.last().unwrap(); - assert_eq!(batch.previous_revision, revision - 1); - assert_eq!(batch.revision, revision); - for (sequence, change) in batch.changes.as_array().unwrap().iter().enumerate() { - assert_eq!(change["sequence"], sequence); - assert!(matches!(change["kind"].as_str(), Some("upsert" | "delete"))); - assert!(change["resource"].is_string()); - assert!(change["id"].is_string()); - assert!(change.get("event").is_none()); - } - workspaces - .iter() - .map(|workspace| { - WorkspacePublicId::parse(workspace["id"].as_str().unwrap()).unwrap() - }) - .collect() + fn kitty_inflight_uploads_share_the_process_byte_budget() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); } + wait_for_kitty_image_budget(&mux); + let per_surface_limit = surfaces[0] + .with_terminal(|terminal| terminal.kitty_inflight_storage_limit()) + .unwrap() as usize; + let segment_bytes = (per_surface_limit.saturating_mul(3) / 5) / 4 * 4; + let first = + format!("\x1b_Ga=t,t=d,f=24,i=991,s=1,v=1,m=1,q=2;{}\x1b\\", "A".repeat(segment_bytes)); + let second = format!("\x1b_Ga=t,t=d,f=24,i=991,m=1,q=2;{}", "A".repeat(segment_bytes)); + let inflight_is_bounded = surfaces[0] + .with_terminal(|terminal| { + terminal.vt_write(first.as_bytes()); + terminal.vt_write(second.as_bytes()); + terminal.preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES).is_err() + }) + .unwrap(); + assert!( + inflight_is_bounded, + "completed and current Kitty upload chunks retained more than one surface byte share" + ); + } - let mux = test_mux(); - let first = mux.create_empty_workspace(Some("One".into()), None, Some(0)).unwrap(); - assert_public_state(&mux, 1, &[("One", true)]); + #[test] + fn kitty_mux_quota_replays_a_maximum_size_inflight_upload() { + use base64::Engine as _; - let second = mux.create_empty_workspace(Some("Two".into()), None, Some(1)).unwrap(); - let ids = assert_public_state(&mux, 2, &[("One", false), ("Two", true)]); + const IMAGE_WIDTH: usize = 2_500; + const IMAGE_HEIGHT: usize = 1_000; + const IMAGE_ID: u32 = 992; - assert_eq!( - mux.rename_workspace_at_revision(first.workspace, "Renamed".into(), Some(2)).unwrap(), - Some(3) - ); - assert_public_state(&mux, 3, &[("Renamed", false), ("Two", true)]); + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); - assert_eq!( - mux.move_workspace_at_revision(first.workspace, 2, Some(3)).unwrap(), - Some((4, true)) + let pixels = vec![0xff; IMAGE_WIDTH * IMAGE_HEIGHT * 4]; + assert_eq!(pixels.len(), ghostty_vt::MAX_KITTY_IMAGE_BYTES); + let payload = base64::engine::general_purpose::STANDARD.encode(&pixels); + let (first_payload, final_payload) = payload.split_at(payload.len() - 4); + let first_chunk = format!( + "\x1b_Ga=t,t=d,f=32,i={IMAGE_ID},s={IMAGE_WIDTH},v={IMAGE_HEIGHT},m=1,q=2;{first_payload}\x1b\\" ); - let moved_ids = assert_public_state(&mux, 4, &[("Two", true), ("Renamed", false)]); - assert_eq!(moved_ids, vec![ids[1].clone(), ids[0].clone()]); - - assert_eq!(mux.close_workspace_at_revision(first.workspace, Some(4)).unwrap(), Some(5)); - let remaining = assert_public_state(&mux, 5, &[("Two", true)]); - assert_eq!(remaining, vec![ids[1].clone()]); - assert_eq!(second.workspace, mux.with_state(|state| state.workspaces[0].id)); - } - - fn assert_ordinary_public_revision(mux: &Mux, revision: u64) -> Value { - assert_eq!(mux.resource_event_epoch(), revision); - mux.with_state(|state| assert_eq!(state.resource_revision, revision)); - let snapshot = crate::resource_api::public_session_snapshot(mux).unwrap(); - assert_eq!(snapshot["session"]["revision"], revision.to_string()); + let final_chunk = format!("\x1b_Gm=0,q=2;{final_payload}\x1b\\"); + assert!(first_chunk.len() <= ghostty_vt::KITTY_INFLIGHT_REPLAY_MAX_BYTES); - let registry = mux.workspace_registry.lock().unwrap(); - assert_eq!(registry.resource_topology_snapshot().unwrap().revision, revision); - let events = registry.resource_events_after(0).unwrap(); - assert_eq!(events.batches.len(), usize::try_from(revision).unwrap()); - let batch = events.batches.last().unwrap(); - assert_eq!(batch.previous_revision, revision - 1); - assert_eq!(batch.revision, revision); - let changes = batch.changes.as_array().unwrap(); - assert!(!changes.is_empty()); - for (sequence, change) in changes.iter().enumerate() { - assert_eq!(change["sequence"], sequence); - assert!(matches!(change["kind"].as_str(), Some("upsert" | "delete"))); - assert!(change["resource"].is_string()); - assert!(change["id"].is_string()); - assert!(change.get("event").is_none()); - } - snapshot + surface + .with_terminal(|terminal| { + let limits = terminal.kitty_graphics_limits().unwrap(); + assert_eq!(limits.image_bytes, ghostty_vt::MAX_KITTY_IMAGE_BYTES as u64); + terminal.vt_write(first_chunk.as_bytes()); + terminal + .preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) + .expect("Mux quota rejected replay for a permitted maximum-size Kitty image"); + terminal.vt_write(final_chunk.as_bytes()); + assert_eq!( + terminal + .kitty_graphics_snapshot() + .unwrap() + .image(IMAGE_ID) + .expect("maximum-size Kitty image was not admitted") + .data + .len(), + pixels.len() + ); + }) + .unwrap(); } #[test] - fn ordinary_topology_creates_renames_and_focus_publish_one_revision_each() { + fn closing_kitty_surfaces_rebalances_the_survivors_process_share() { let mux = test_mux(); - let first = mux.new_workspace(Some("One".into()), None).unwrap(); - assert_ordinary_public_revision(&mux, 1); - let (first_workspace, first_screen, first_pane) = mux.with_state(|state| { - let pane = state.pane_of(first.id).unwrap(); - let (workspace, screen) = state.screen_of(pane).unwrap(); - (state.workspaces[workspace].id, state.workspaces[workspace].screens[screen].id, pane) - }); - - let second_tab = mux.new_tab(Some(first_pane), None, None).unwrap(); - assert_ordinary_public_revision(&mux, 2); - let browser = mux - .new_browser_tab("about:blank#ordinary-public".into(), Some(first_pane), None) + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first.clone()]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); + let constrained = first + .with_terminal(|terminal| { + ( + terminal.kitty_image_storage_limit().unwrap(), + terminal.kitty_image_count_limit().unwrap(), + terminal.kitty_placement_count_limit().unwrap(), + ) + }) .unwrap(); - assert_ordinary_public_revision(&mux, 3); - let second_screen_surface = mux.new_screen(Some(first_workspace), None).unwrap(); - assert_ordinary_public_revision(&mux, 4); - let second_pane = mux.with_state(|state| state.pane_of(second_screen_surface.id).unwrap()); - let split_surface = mux.split(second_pane, SplitDir::Right, None).unwrap(); - assert_ordinary_public_revision(&mux, 5); - let split_pane = mux.with_state(|state| state.pane_of(split_surface.id).unwrap()); - let distributed_surface = mux.new_pane(split_pane, None).unwrap(); - assert_ordinary_public_revision(&mux, 6); - let distributed_pane = - mux.with_state(|state| state.pane_of(distributed_surface.id).unwrap()); + for surface in surfaces.iter().skip(1) { + close_terminal_runtime_for_test(&mux, surface); + } + wait_for_kitty_image_budget(&mux); - assert!(mux.rename_pane(distributed_pane, "Worker".into())); - assert_ordinary_public_revision(&mux, 7); - assert!(mux.rename_surface(browser.id, "Docs".into())); - assert_ordinary_public_revision(&mux, 8); - let second_screen = mux.with_state(|state| { - let (workspace, screen) = state.screen_of(second_pane).unwrap(); - state.workspaces[workspace].screens[screen].id - }); - assert!(mux.rename_screen(second_screen, "Build".into())); - let snapshot = assert_ordinary_public_revision(&mux, 9); - let pane_public = - mux.with_state(|state| state.resource_indexes.pane_ids[&distributed_pane].to_string()); - let tab_public = - mux.with_state(|state| state.resource_indexes.tab_ids[&browser.id].to_string()); - let screen_public = - mux.with_state(|state| state.resource_indexes.screen_ids[&second_screen].to_string()); - assert_eq!( - snapshot["panes"] - .as_array() - .unwrap() - .iter() - .find(|pane| pane["id"] == pane_public) - .unwrap()["name"], - "Worker" + let survivor_limit = first + .with_terminal(|terminal| { + ( + terminal.kitty_image_storage_limit().unwrap(), + terminal.kitty_image_count_limit().unwrap(), + terminal.kitty_placement_count_limit().unwrap(), + ) + }) + .unwrap(); + let expected = kitty_image_limits_for_capacity(1).image_bytes; + assert!( + survivor_limit.0 > constrained.0, + "surviving terminal kept its peak-surface quota of {} bytes", + survivor_limit.0 ); - assert_eq!( - snapshot["tabs"] - .as_array() - .unwrap() - .iter() - .find(|tab| tab["id"] == tab_public) - .unwrap()["name"], - "Docs" + assert_eq!(survivor_limit.0, expected); + assert!( + survivor_limit.1 > constrained.1, + "surviving terminal kept its peak-surface image count of {}", + survivor_limit.1 ); - assert_eq!( - snapshot["screens"] - .as_array() - .unwrap() - .iter() - .find(|screen| screen["id"] == screen_public) - .unwrap()["name"], - "Build" + assert!( + survivor_limit.2 > constrained.2, + "surviving terminal kept its peak-surface placement count of {}", + survivor_limit.2 ); + assert_eq!(survivor_limit.1, 2_048); + assert_eq!(survivor_limit.2, 8_192); + } - assert!(mux.focus_pane(second_pane)); - assert_ordinary_public_revision(&mux, 10); - mux.select_tab(Some(first_pane), Some(0), None); - assert_ordinary_public_revision(&mux, 11); - mux.with_state(|state| assert_eq!(state.active_pane(), Some(second_pane))); - mux.select_screen(Some(0), None); - assert_ordinary_public_revision(&mux, 12); - mux.with_state(|state| { - assert_eq!( - state.workspaces[0].screens[state.workspaces[0].active_screen].id, - first_screen - ); - }); + #[test] + fn killed_kitty_surface_cannot_block_the_next_terminal_reservation() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut survivors = Vec::new(); + for _ in 1..4 { + survivors.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); - mux.new_workspace(Some("Two".into()), None).unwrap(); - assert_ordinary_public_revision(&mux, 13); - mux.select_workspace(Some(0), None); - let snapshot = assert_ordinary_public_revision(&mux, 14); - assert_eq!( - snapshot["workspaces"] - .as_array() - .unwrap() - .iter() - .find(|workspace| workspace["name"] == "One") - .unwrap()["focused"], - true + let first_id = first.id; + *mux.kitty_image_budget_operation.lock().unwrap() = + Some(Arc::new(move |surface, limits, _deadline| { + if surface.id == first_id { + anyhow::bail!("a killed terminal host cannot acknowledge quota updates"); + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + })); + + first.kill(); + let started = Instant::now(); + let replacement = mux + .new_tab(Some(pane), None, Some((80, 24))) + .expect("a killed surface retained quota and blocked terminal creation"); + assert!( + started.elapsed() < Duration::from_millis(250), + "terminal creation waited on a killed surface for {:?}", + started.elapsed() ); - assert_eq!(second_tab.id, mux.with_state(|state| state.panes[&first_pane].tabs[1])); + *mux.kitty_image_budget_operation.lock().unwrap() = None; + close_terminal_runtime_for_test(&mux, &replacement); + for surface in survivors { + close_terminal_runtime_for_test(&mux, &surface); + } + close_terminal_runtime_for_test(&mux, &first); + wait_for_kitty_image_budget(&mux); } #[test] - fn durable_workspace_creation_supports_the_in_process_terminal_runtime() { - let mux = Mux::new( - format!("in-process-resource-{}", WorkspacePublicId::random().unwrap()), - SurfaceOptions::default(), - ); + fn kitty_quota_updates_delay_terminal_creation_until_startup_is_safe() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let gate = Arc::new((Mutex::new(false), Condvar::new())); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let gate = gate.clone(); + move |surface, limits, _deadline| { + if surface.id == first.id { + let (released, changed) = &*gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + let (sender, receiver) = std::sync::mpsc::channel(); + let creating_mux = mux.clone(); + let creator = std::thread::spawn(move || { + let result = creating_mux.new_tab(Some(pane), None, Some((80, 24))); + let _ = sender.send(result); + }); + + let created_without_waiting = receiver.recv_timeout(Duration::from_millis(250)).ok(); + let returned_before_release = created_without_waiting.is_some(); + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + let surface = match created_without_waiting { + Some(result) => result.unwrap(), + None => receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(), + }; + creator.join().unwrap(); + *mux.kitty_image_budget_operation.lock().unwrap() = None; + wait_for_kitty_image_budget(&mux); - let surface = mux.new_workspace(Some("headless".into()), Some((80, 24))).unwrap(); - let identity = mux - .resource_terminal_host_identity(&surface) - .expect("reserved in-process terminal has a durable lifecycle identity"); assert!( - mux.reserved_in_process_terminals.lock().unwrap().contains_key(&surface.id), - "in-process reservation must remain available to close and exit paths" + !returned_before_release, + "terminal creation bypassed the in-flight Kitty quota shrink" ); - let snapshot = mux.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); - assert_eq!(snapshot.revision, 1); - assert_eq!(snapshot.active_screens.len(), 1); - assert_eq!(snapshot.tabs.len(), 1); - assert_eq!(snapshot.tabs[0].terminal_id.as_deref(), Some(identity.terminal_id.as_str())); + close_terminal_runtime_for_test(&mux, &surface); + } - let workspace = mux.with_state(|state| state.workspaces[0].id); - assert!(mux.close_workspace(workspace)); - mux.shutdown(); + #[test] + fn kitty_quota_restoration_uses_linear_bucket_updates() { + let mux = test_mux(); + let applications = Arc::new(AtomicUsize::new(0)); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let applications = applications.clone(); + move |surface, limits, _deadline| { + applications.fetch_add(1, Ordering::AcqRel); + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first]; + for _ in 1..16 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + + wait_for_kitty_image_budget(&mux); + + let applied = applications.load(Ordering::Acquire); + assert!( + applied <= surfaces.len() * 4, + "restoring {} terminals applied {applied} Kitty quota updates", + surfaces.len() + ); } #[test] - fn ordinary_topology_projection_failure_keeps_memory_and_public_state_unchanged() { + fn kitty_quota_expansion_delays_concurrent_terminal_until_reshrink() { let mux = test_mux(); - let surface = mux.new_workspace(None, None).unwrap(); - let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); + let survivor = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(survivor.id).unwrap()); + let mut surfaces = vec![survivor.clone()]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); - assert!(!mux.rename_pane(pane, "Never visible".into())); + let expansion = kitty_image_limits_for_capacity(1); + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let gate = gate.clone(); + let survivor_id = survivor.id; + move |surface, limits, _deadline| { + if surface.id == survivor_id && limits == expansion { + let _ = started_sender.try_send(()); + let (released, changed) = &*gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + for surface in surfaces.iter().skip(1) { + close_terminal_runtime_for_test(&mux, surface); + } + started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); - assert_eq!(mux.resource_event_epoch(), 1); - mux.with_state(|state| { - assert_eq!(state.resource_revision, 1); - assert_eq!(state.panes[&pane].name, None); + let (sender, receiver) = std::sync::mpsc::channel(); + let creating_mux = mux.clone(); + let creator = std::thread::spawn(move || { + let _ = sender.send(creating_mux.new_tab(Some(pane), None, Some((80, 24)))); }); - let registry = mux.workspace_registry.lock().unwrap(); - assert_eq!(registry.resource_topology_snapshot().unwrap().revision, 1); - assert_eq!(registry.resource_events_after(0).unwrap().batches.len(), 1); - registry.set_resource_patch_failure(false).unwrap(); + let returned_before_release = receiver.recv_timeout(Duration::from_millis(250)).ok(); + assert!( + returned_before_release.is_none(), + "terminal creation bypassed a stale expansion and consumed output without safe quota" + ); + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + let concurrent = receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(); + creator.join().unwrap(); + wait_for_kitty_image_budget(&mux); + *mux.kitty_image_budget_operation.lock().unwrap() = None; + let settled = kitty_image_limits_for_capacity(2).image_bytes; + for surface in [&survivor, &concurrent] { + assert_eq!( + surface + .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) + .unwrap(), + settled + ); + } } #[test] - fn ordinary_workspace_projection_failure_rolls_back_both_registries_and_memory() { + fn terminal_creation_never_exposes_a_disabled_kitty_quota() { let mux = test_mux(); - mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); + let survivor = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(survivor.id).unwrap()); + let mut surfaces = vec![survivor.clone()]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); - let error = - mux.create_empty_workspace(Some("Never visible".into()), None, Some(0)).unwrap_err(); - assert!(error.to_string().contains("forced resource patch failure")); - assert_eq!(mux.resource_event_epoch(), 0); - mux.with_state(|state| { - assert!(state.workspaces.is_empty()); - assert_eq!(state.workspace_revision, 0); - assert_eq!(state.resource_revision, 0); + let expansion = kitty_image_limits_for_capacity(1); + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let gate = gate.clone(); + let survivor_id = survivor.id; + move |surface, limits, _deadline| { + if surface.id == survivor_id && limits == expansion { + let _ = started_sender.try_send(()); + let (released, changed) = &*gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + for surface in surfaces.iter().skip(1) { + close_terminal_runtime_for_test(&mux, surface); + } + started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + + let (sender, receiver) = std::sync::mpsc::channel(); + let creating_mux = mux.clone(); + let creator = std::thread::spawn(move || { + let _ = sender.send(creating_mux.new_tab(Some(pane), None, Some((80, 24)))); }); - let registry = mux.workspace_registry.lock().unwrap(); - let snapshot = registry.snapshot().unwrap(); - assert_eq!(snapshot.revision, 0); - assert_eq!(snapshot.resource_revision, 0); - assert!(snapshot.workspaces.is_empty()); - assert!(registry.resource_events_after(0).unwrap().batches.is_empty()); - registry.set_resource_patch_failure(false).unwrap(); - } + let created_before_rebalance = receiver.recv_timeout(Duration::from_millis(250)).ok(); + let startup_limit = created_before_rebalance.as_ref().map(|result| { + result + .as_ref() + .unwrap() + .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) + .unwrap() + }); + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + let concurrent = match created_before_rebalance { + Some(result) => result.unwrap(), + None => receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(), + }; + creator.join().unwrap(); + *mux.kitty_image_budget_operation.lock().unwrap() = None; + wait_for_kitty_image_budget(&mux); - #[test] - fn resource_idempotency_is_session_global_and_rejects_changed_input() { - let mux = test_mux(); - let first = WorkspaceMutation::new("global-key", "client-a").unwrap(); - mux.resource_create_empty_workspace(Some("One".into()), None, Some(0), &first).unwrap(); - let reused = WorkspaceMutation::new("global-key", "client-b").unwrap(); - let error = mux - .resource_create_empty_workspace(Some("Two".into()), None, Some(1), &reused) - .unwrap_err(); - assert!(error.to_string().contains("idempotency.conflict")); - mux.with_state(|state| { - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].name, "One"); + let initial_limit = startup_limit.unwrap_or_else(|| { + concurrent + .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) + .unwrap() }); + assert!( + initial_limit > 0, + "a newly launched terminal could consume startup output while Kitty graphics were disabled" + ); } #[test] - fn resource_results_never_expose_numeric_workspace_slots() { + fn kitty_quota_exhaustion_disables_only_overflow_surfaces_and_promotes_them() { let mux = test_mux(); - let result = mux - .resource_create_empty_workspace( - Some("Public".into()), - None, - Some(0), - &WorkspaceMutation::new("public-only", "test").unwrap(), - ) - .unwrap() - .result; - let object = result.as_object().unwrap(); + let opts = mux.surface_options.lock().unwrap().clone(); + let owner_limit = + usize::try_from(KITTY_IMAGE_PROCESS_BUDGET_COUNT / KITTY_OBJECT_OWNERS_PER_SURFACE) + .unwrap(); + assert_eq!(owner_limit, 2_048); + let mut surfaces = Vec::with_capacity(owner_limit + 1); + for _ in 0..=owner_limit { + surfaces.push( + Surface::spawn_for_test(mux.next_id(), opts.clone(), Arc::downgrade(&mux)).unwrap(), + ); + } + wait_for_kitty_image_budget(&mux); + + let participating_limit = surfaces[0] + .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) + .unwrap(); + assert!(participating_limit > 0); assert_eq!( - object.keys().map(String::as_str).collect::>(), - HashSet::from(["workspace", "name", "index"]) + surfaces[owner_limit] + .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) + .unwrap(), + 0, + "quota exhaustion disabled terminals that already owned a graphics share" + ); + + mux.unregister_kitty_image_surface(&surfaces[0]).unwrap(); + wait_for_kitty_image_budget(&mux); + assert_eq!( + surfaces[owner_limit] + .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) + .unwrap(), + participating_limit, + "an overflow terminal was not promoted when a graphics share became available" ); - assert!(WorkspacePublicId::parse(object["workspace"].as_str().unwrap()).is_ok()); - let encoded = serde_json::to_string(&result).unwrap(); - for forbidden in ["key", "workspace_key", "slot", "numeric_id", "short_id", "surface"] { - assert!(!encoded.contains(forbidden), "leaked {forbidden}: {encoded}"); - } } #[test] - fn resource_large_workspace_mutations_are_targeted_and_query_bounded() { - const WORKSPACE_COUNT: usize = 1_000; + fn kitty_quota_worker_retries_a_transient_update_failure() { let mux = test_mux(); - let mut durable = Vec::with_capacity(WORKSPACE_COUNT); - let mut memory = Vec::with_capacity(WORKSPACE_COUNT); - let mut order = Vec::with_capacity(WORKSPACE_COUNT); - for index in 0..WORKSPACE_COUNT { - let public_id = restore_workspace_id(index as u128 + 1); - let key = format!("00000000-0000-4000-8000-{index:012x}"); - let slot = mux.next_id(); - let name = format!("Workspace {}", index + 1); - durable.push(ResourceChange::UpsertWorkspace { - workspace: RegistryWorkspace { - id: slot, - public_id: public_id.clone(), - key: key.clone(), - name: name.clone(), - group_key: mux.session.clone(), - }, - position: index, - active_screen: None, - }); - memory.push(Workspace { - id: slot, - public_id: public_id.clone(), - key, - name, - screens: Vec::new(), - active_screen: 0, - }); - order.push(public_id); - } - durable.push(ResourceChange::SetWorkspaceOrder { workspace_ids: order.clone() }); - durable.push(ResourceChange::SetActiveWorkspace { workspace_id: order.first().cloned() }); - { - let mut registry = mux.workspace_registry.lock().unwrap(); - let commit = registry - .commit_resource_patch( - &WorkspaceMutation::new("seed-thousand", "test").unwrap(), - "session.seed", - &serde_json::json!({"count":WORKSPACE_COUNT}), - None, - Some(0), - &ResourcePatch { changes: durable }, - &serde_json::json!({"count":WORKSPACE_COUNT}), - &serde_json::json!([{"kind":"session.seeded"}]), + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move |surface, limits, _deadline| { + if attempts.fetch_add(1, Ordering::AcqRel) == 0 { + anyhow::bail!("injected transient Kitty quota failure"); + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, ) - .unwrap(); - let mut state = mux.state.lock().unwrap(); - state.workspaces.reserve(WORKSPACE_COUNT); - state.workspace_index_by_id.reserve(WORKSPACE_COUNT); - state.workspace_id_by_key.reserve(WORKSPACE_COUNT); - state.resource_indexes.workspaces.reserve(WORKSPACE_COUNT); - state.resource_indexes.workspace_ids.reserve(WORKSPACE_COUNT); - for workspace in memory { - state.push_workspace(workspace); } - state.active_workspace = 0; - state.resource_revision = commit.revision; + })); + + close_terminal_runtime_for_test(&mux, &second); + let deadline = Instant::now() + Duration::from_secs(1); + while attempts.load(Ordering::Acquire) < 2 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); } - let target = order[499].clone(); - let renamed = mux - .resource_rename_workspace( - &target, - "Renamed".into(), - None, - Some(1), - &WorkspaceMutation::new("rename-thousand", "test").unwrap(), - ) - .unwrap(); - assert_eq!(renamed.revision, 2); - assert_eq!( - mux.last_resource_mutation_metrics(), - ResourceMutationMetrics { - touched_resources: 1, - order_entries: 0, - terminal_queries: 0, - changed_rows: 1, - } + assert!( + attempts.load(Ordering::Acquire) >= 2, + "Kitty quota worker stopped after a transient failure" ); - mux.with_state(|state| { - assert_eq!(state.workspace_by_public_id(&target).unwrap().name, "Renamed"); - }); + wait_for_kitty_image_budget(&mux); + } - let moved = mux - .resource_move_workspace( - &target, - WORKSPACE_COUNT - 1, - None, - Some(2), - &WorkspaceMutation::new("move-thousand", "test").unwrap(), - ) - .unwrap(); - assert_eq!(moved.revision, 3); - assert_eq!( - mux.last_resource_mutation_metrics(), - ResourceMutationMetrics { - touched_resources: 1, - order_entries: WORKSPACE_COUNT, - terminal_queries: 0, - changed_rows: WORKSPACE_COUNT, + #[test] + fn kitty_quota_worker_disables_graphics_but_admits_terminals_after_persistent_failure() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move |_surface, _limits, _deadline| { + attempts.fetch_add(1, Ordering::AcqRel); + anyhow::bail!("injected persistent Kitty quota failure") } + })); + + close_terminal_runtime_for_test(&mux, &second); + let deadline = Instant::now() + Duration::from_secs(2); + while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + + assert!( + !mux.kitty_image_budget.lock().unwrap().worker_running, + "Kitty quota worker retried a permanent failure forever" + ); + assert!( + attempts.load(Ordering::Acquire) <= 4, + "Kitty quota worker exceeded its retry budget" ); - mux.with_state(|state| { - assert_eq!(state.workspaces.last().unwrap().public_id, target); - assert_eq!(state.workspaces.len(), WORKSPACE_COUNT); - }); - let registry = mux.workspace_registry.lock().unwrap().snapshot().unwrap(); - assert_eq!(registry.resource_revision, 3); - assert_eq!(registry.workspaces.last().unwrap().public_id, target); - assert_eq!(registry.workspaces.last().unwrap().name, "Renamed"); - } - #[test] - fn resource_startup_revalidates_exact_layout_and_viewport_coverage() { - let (snapshot, mut topology) = resource_restore_fixture(); - topology.panes[3].screen_id = restore_screen_id(2); + let started = Instant::now(); + let replacement = mux + .new_tab(Some(pane), None, Some((80, 24))) + .expect("an optional Kitty quota failure blocked terminal creation"); + assert!( + started.elapsed() < Duration::from_millis(250), + "a blocked Kitty quota transition waited for the control timeout" + ); assert_eq!( - restore_resource_state(snapshot.clone(), topology).err().unwrap().to_string(), - format!("screen {} layout does not cover its panes exactly once", restore_screen_id(1)) + replacement + .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) + .unwrap(), + 0, + "a terminal admitted during a blocked quota transition retained graphics quota" ); - - let (_, mut topology) = resource_restore_fixture(); - topology.screens[0].viewport.columns[1].id = restore_split_id(999); + let budget = mux.kitty_image_budget.lock().unwrap(); assert!( - restore_resource_state(snapshot, topology) - .err() - .unwrap() - .to_string() - .contains("unknown projected split") + !budget.entries.get(&replacement.id).unwrap().owns_quota, + "a terminal admitted during a blocked quota transition became a quota owner" ); } - fn resource_restore_patch( - snapshot: &RegistrySnapshot, - topology: &ResourceTopologySnapshot, - ) -> ResourcePatch { - let active_screens = topology.active_screens.iter().cloned().collect::>(); - let mut changes = Vec::new(); - for (position, workspace) in snapshot.workspaces.iter().enumerate() { - changes.push(ResourceChange::UpsertWorkspace { - workspace: workspace.clone(), - position, - active_screen: active_screens.get(&workspace.public_id).cloned().flatten(), - }); - } - changes.extend(topology.screens.iter().cloned().map(ResourceChange::UpsertScreen)); - changes.extend(topology.panes.iter().cloned().map(ResourceChange::UpsertPane)); - changes.extend(topology.browsers.iter().cloned().map(ResourceChange::UpsertBrowser)); - for tab in &topology.tabs { - let ContentPublicId::Browser(_) = &tab.content_id else { - panic!("restart fixture uses browser tabs"); - }; - changes.push(ResourceChange::UpsertTab(tab.clone())); - } - changes.push(ResourceChange::SetWorkspaceOrder { - workspace_ids: snapshot - .workspaces - .iter() - .map(|workspace| workspace.public_id.clone()) - .collect(), - }); - for workspace in &snapshot.workspaces { - changes.push(ResourceChange::SetScreenOrder { - workspace_id: workspace.public_id.clone(), - screen_ids: topology - .screens - .iter() - .filter(|screen| screen.workspace_id == workspace.public_id) - .map(|screen| screen.public_id.clone()) - .collect(), - }); - } - for pane in &topology.panes { - changes.push(ResourceChange::SetTabOrder { - pane_id: pane.public_id.clone(), - tab_ids: topology - .tabs - .iter() - .filter(|tab| tab.pane_id == pane.public_id) - .map(|tab| tab.public_id.clone()) - .collect(), - }); + #[test] + fn kitty_quota_reconnect_clears_its_block_and_rebalances() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let first_id = first.id; + *mux.kitty_image_budget_operation.lock().unwrap() = + Some(Arc::new(move |surface, limits, _deadline| { + if surface.id == first_id { + anyhow::bail!("injected disconnected Kitty quota surface"); + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + })); + + close_terminal_runtime_for_test(&mux, &second); + let deadline = Instant::now() + Duration::from_secs(2); + while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); } - changes.push(ResourceChange::SetActiveWorkspace { - workspace_id: topology.active_workspace.clone(), - }); - ResourcePatch { changes } + assert!(mux.kitty_image_budget.lock().unwrap().blocked_surfaces.contains(&first.id)); + + *mux.kitty_image_budget_operation.lock().unwrap() = None; + let authoritative = + first.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(); + assert!(mux.reconcile_reconnected_kitty_image_surface(&first, authoritative)); + wait_for_kitty_image_budget(&mux); + + let replacement = mux + .new_tab(Some(pane), None, Some((80, 24))) + .expect("a reconciled reconnect must admit later terminals"); + wait_for_kitty_image_budget(&mux); + close_terminal_runtime_for_test(&mux, &replacement); } #[test] - fn persistent_mux_restart_keeps_public_topology_and_browser_tabs() { - let root = std::env::temp_dir() - .join(format!("cmux-resource-restart-{}", WorkspacePublicId::random().unwrap())); - let (fixture_snapshot, fixture_topology) = resource_restore_fixture(); + fn kitty_quota_worker_stops_waiting_for_an_operation_that_ignores_its_deadline() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); + let (finished_sender, finished_receiver) = std::sync::mpsc::sync_channel(1); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let gate = gate.clone(); + move |_surface, _limits, _deadline| { + let _ = started_sender.try_send(()); + let (released, changed) = &*gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + let _ = finished_sender.try_send(()); + anyhow::bail!("released persistent Kitty quota operation") + } + })); + + close_terminal_runtime_for_test(&mux, &second); + started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(4); + while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let stopped = !mux.kitty_image_budget.lock().unwrap().worker_running; { - let mut registry = WorkspaceRegistry::open(&root, "restart").unwrap(); - registry - .commit_resource_patch( - &WorkspaceMutation::new("seed-restart", "test").unwrap(), - "session.restore_fixture", - &serde_json::json!({"fixture":"nested-columns"}), - None, - Some(0), - &resource_restore_patch(&fixture_snapshot, &fixture_topology), - &serde_json::json!({"restored":true}), - &serde_json::json!([{"event":"session.restored"}]), - ) - .unwrap(); + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); } + finished_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); - let registry = WorkspaceRegistry::open(&root, "restart").unwrap(); - let mux = Mux::from_workspace_registry( - "restart".into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); - mux.with_state(|state| { - assert_eq!(state.workspaces.len(), 2); - assert_eq!(state.active_workspace, 1); - assert!(state.workspaces[1].screens.is_empty()); - assert_eq!(state.workspaces[0].active_screen, 1); - assert_eq!(state.workspaces[0].screens[0].layout_columns.len(), 2); - assert!(state.workspaces[0].screens[0].layout_column_projection_is_consistent()); - assert_eq!(state.surfaces.len(), fixture_topology.tabs.len()); - for tab in &fixture_topology.tabs { - let slot = state.resource_indexes.tabs[&tab.public_id]; - let surface = &state.surfaces[&slot]; - let ContentPublicId::Browser(browser_id) = &tab.content_id else { - unreachable!("restart fixture uses browser tabs"); - }; - let expected_browser = fixture_topology - .browsers - .iter() - .find(|browser| &browser.public_id == browser_id) - .unwrap(); - assert_eq!(surface.resource_identity().unwrap().tab_id, tab.public_id); - assert_eq!(surface.resource_identity().unwrap().content_id, tab.content_id); - assert_eq!(surface.name(), tab.name); - assert_eq!(surface.browser_url().as_deref(), Some(expected_browser.url.as_str())); - assert_eq!(surface.size(), (expected_browser.cols, expected_browser.rows)); - } - }); - mux.shutdown(); - drop(mux); - std::fs::remove_dir_all(root).unwrap(); + assert!(stopped, "Kitty quota worker waited forever for an operation past its deadline"); } #[test] - fn restart_finishes_a_close_committed_before_live_state_detach() { - let root = std::env::temp_dir() - .join(format!("cmux-resource-close-crash-{}", WorkspacePublicId::random().unwrap())); - let session = "resource-close-crash"; - let session_selectors = serde_json::json!({"machine":"current","session":"current"}); - let registry = WorkspaceRegistry::open(&root, session).unwrap(); - let mux = Mux::from_workspace_registry( - session.into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); - let created = public_request( - &mux, - "create-workspace", - "workspace.create", - serde_json::json!({ - "machine":"current", - "session":"current", - "name":"close crash", - "initial_content":"empty", - }), - Some("close-crash-create"), - ); - let workspace = created["result"]["value"]["workspace_id"].as_str().unwrap().to_string(); - let before_revision = - created["result"]["revision"].as_str().unwrap().parse::().unwrap(); - mux.set_resource_close_after_commit_hook_for_test(Some(Arc::new(|| { - panic!("simulated daemon crash after close commit") - }))); - let crashed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - public_request( - &mux, - "close-workspace", - "workspace.close", - serde_json::json!({ - "machine":"current", - "session":"current", - "workspace":workspace, + fn kitty_quota_worker_exhausts_deadline_pool_admission_rejections() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + { + let mut state = mux + .deadline_fanout_pool + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert_eq!(state.admitted_jobs, 0); + state.admitted_jobs = CELL_PIXEL_FANOUT_MAX_WORKERS; + } + + close_terminal_runtime_for_test(&mux, &second); + let deadline = Instant::now() + Duration::from_secs(2); + while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let (stopped, survivor_owns_quota, closed_cleanup_retained) = { + let budget = mux.kitty_image_budget.lock().unwrap(); + ( + !budget.worker_running, + budget.entries.get(&first.id).is_some_and(|entry| { + entry.owns_quota && !budget.blocked_surfaces.contains(&first.id) }), - Some("close-crash-effect"), + budget.entries.get(&second.id).is_some_and(|entry| entry.removing) + && budget.blocked_surfaces.contains(&second.id), ) - })); - assert!(crashed.is_err()); - drop(mux); + }; - let registry = WorkspaceRegistry::open(&root, session).unwrap(); - let reopened = Mux::from_workspace_registry( - session.into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); - let snapshot = crate::resource_api::public_session_snapshot(&reopened).unwrap(); - assert!(snapshot["workspaces"].as_array().unwrap().is_empty()); - let events = reopened.resource_events_after(before_revision).unwrap(); - assert_eq!(events.batches.len(), 1); - assert_eq!(events.batches[0].revision, before_revision + 1); - let replay = public_request( - &reopened, - "close-replay", - "workspace.close", - serde_json::json!({ - "machine":"current", - "session":"current", - "workspace":workspace, - }), - Some("close-crash-effect"), + { + let mut state = mux + .deadline_fanout_pool + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.admitted_jobs = 0; + mux.deadline_fanout_pool.inner.changed.notify_all(); + } + assert!(stopped, "Kitty quota worker retried pool admission forever"); + assert!( + survivor_owns_quota, + "a closing surface's failed cleanup disabled an unaffected live surface" ); - assert_eq!(replay["result"]["replayed"], true); - assert_eq!(replay["result"]["revision"], (before_revision + 1).to_string()); - assert_eq!(reopened.resource_events_after(before_revision).unwrap().batches.len(), 1); - assert_eq!( - public_request(&reopened, "snapshot", "session.snapshot", session_selectors, None,)["result"] - ["workspaces"], - serde_json::json!([]) + assert!( + closed_cleanup_retained, + "pool admission exhaustion discarded the closing surface's cleanup owner" ); - reopened.shutdown(); - drop(reopened); - std::fs::remove_dir_all(root).unwrap(); } #[test] - fn terminal_close_runs_runtime_cleanup_outside_creation_fence() { - let mux = test_mux(); - let created = public_request( - &mux, - "create-terminal-workspace", - "workspace.create", - serde_json::json!({ - "machine":"current", - "session":"current", - "name":"close fence", - "initial_content":"terminal", - }), - Some("create-terminal-workspace"), - ); - let terminal = created["result"]["value"]["terminal_id"].clone(); - let cleanup_held_creation_fence = Arc::new(AtomicBool::new(false)); - mux.set_resource_close_cleanup_hook_for_test(Some(Arc::new({ - let cleanup_held_creation_fence = cleanup_held_creation_fence.clone(); - let mux = Arc::downgrade(&mux); - move || { - cleanup_held_creation_fence.store( - mux.upgrade() - .is_some_and(|mux| mux.resource_creation_execution.try_lock().is_err()), - Ordering::Release, - ); - } - }))); - public_request( - &mux, - "close-terminal", - "terminal.close", - serde_json::json!({ - "machine":"current", - "session":"current", - "terminal":terminal, - }), - Some("close-terminal"), - ); - assert!( - !cleanup_held_creation_fence.load(Ordering::Acquire), - "terminal cleanup ran while holding the global creation fence" + fn cell_pixel_fanout_runs_concurrently_with_one_shared_deadline() { + let pool = DeadlineFanoutPool::new(); + let (warm_tx, warm_rx) = std::sync::mpsc::sync_channel(1); + assert!(pool.submit(Box::new(move || warm_tx.send(()).unwrap()))); + warm_rx.recv_timeout(Duration::from_secs(1)).expect("warmup fanout job did not run"); + let items = (0..8).collect::>(); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let deadline = Instant::now() + Duration::from_secs(1); + let operation_active = active; + let operation_max_active = max_active.clone(); + + let results = + bounded_deadline_map(&pool, &items, deadline, move |item, observed_deadline| { + assert_eq!(observed_deadline, deadline); + let concurrent = operation_active.fetch_add(1, Ordering::AcqRel) + 1; + operation_max_active.fetch_max(concurrent, Ordering::AcqRel); + std::thread::sleep(Duration::from_millis(10)); + operation_active.fetch_sub(1, Ordering::AcqRel); + item * 2 + }); + + assert_eq!( + results + .into_iter() + .map(|result| match result { + DeadlineMapResult::Complete(result) => Some(result), + DeadlineMapResult::Pending(_) | DeadlineMapResult::Unscheduled => None, + }) + .collect::>>() + .unwrap(), + vec![0, 2, 4, 6, 8, 10, 12, 14] ); + assert!(max_active.load(Ordering::Acquire) > 1); } #[test] - fn persistent_mux_restart_restores_auxiliary_resources_and_exact_replay() { - let root = std::env::temp_dir().join(format!( - "cmux-resource-auxiliary-restart-{}", - WorkspacePublicId::random().unwrap() - )); - let session = "auxiliary-restart"; - let selectors = serde_json::json!({"machine":"current","session":"current"}); - let create_params = serde_json::json!({ - "machine":"current", - "session":"current", - "name":"Durable resources", - "initial_content":"terminal", - }); - let registry = WorkspaceRegistry::open(&root, session).unwrap(); - let mux = Mux::from_workspace_registry( - session.into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); + fn deadline_fanout_rejects_work_after_the_shared_deadline() { + let pool = DeadlineFanoutPool::new(); + let calls = Arc::new(AtomicUsize::new(0)); + let operation_calls = calls.clone(); + let deadline = Instant::now().checked_sub(Duration::from_millis(1)).unwrap(); - let created = public_request( - &mux, - "create", - "workspace.create", - create_params.clone(), - Some("auxiliary-restart-create"), - ); - let created_value = created["result"]["value"].clone(); - let terminal_id = created_value["terminal_id"].as_str().unwrap().to_string(); - let terminal_public_id = TerminalPublicId::parse(&terminal_id).unwrap(); - let notification_params = serde_json::json!({ - "machine":"current", - "session":"current", - "title":"Durable build", - "body":"All checks passed", - "level":"warning", - "terminal_id":terminal_id, - }); - let notification = public_request( - &mux, - "notification", - "notification.create", - notification_params.clone(), - Some("auxiliary-restart-notification"), - ); - let notification_value = notification["result"]["value"].clone(); - let agent_params = serde_json::json!({ - "machine":"current", - "session":"current", - "terminal_id":terminal_id, - "state":"working", - "source":"hook", - "source_session":"worker-7", - }); - let agent = public_request( - &mux, - "agent", - "agent.report", - agent_params.clone(), - Some("auxiliary-restart-agent"), - ); - let agent_value = agent["result"]["value"].clone(); - let defaults_params = serde_json::json!({ - "machine":"current", - "session":"current", - "foreground":"#123456", - "background":"#654321", - "cursor_style":"bar", - "cursor_blink":true, - "palette":{"1":"#abcdef"}, - "complete":true, + let results = bounded_deadline_map(&pool, &[1_u8], deadline, move |item, _| { + operation_calls.fetch_add(1, Ordering::AcqRel); + *item }); - let defaults = public_request( - &mux, - "defaults", - "session.terminal_defaults.update", - defaults_params.clone(), - Some("auxiliary-restart-defaults"), - ); - let defaults_value = defaults["result"]["value"].clone(); - let projection_id = "projection_00000000000000000000000000000001"; - let projection_params = serde_json::json!({ - "machine":"current", - "session":"current", - "frontend_projection":projection_id, - "frontend_id":"cmux-test", - "window_id":"window-restart", - "generation":"launch-restart", - "projection":{ - "schema":"cmux.sidebar.test/1", - "revision":"7", - "rows":[{"label":"build","state":"working"}], - }, + + assert!(matches!(results.as_slice(), [DeadlineMapResult::Unscheduled])); + assert_eq!(calls.load(Ordering::Acquire), 0); + } + + #[test] + fn fanout_completion_after_the_shared_deadline_remains_retryable() { + let deadline = Instant::now(); + let pending = DeadlinePending { + result: Arc::new(Mutex::new(Some(DeadlineCompletion { + completed_at: deadline + Duration::from_millis(1), + value: 42, + }))), + }; + + assert_eq!(pending.try_take_before(deadline), None); + assert_eq!(pending.try_take(), Some(42)); + } + + #[test] + fn deadline_fanout_workers_are_lazy_and_reclaim_idle_threads() { + let pool = DeadlineFanoutPool::new(); + assert_eq!(pool.worker_count(), 0, "fanout construction eagerly retained workers"); + + let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1); + assert!(pool.submit(Box::new(move || { + finished_tx.send(()).unwrap(); + }))); + finished_rx.recv_timeout(Duration::from_secs(1)).expect("fanout job did not run"); + + let deadline = Instant::now() + Duration::from_secs(1); + while pool.worker_count() != 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(pool.worker_count(), 0, "idle fanout worker was retained"); + + let (teardown_tx, teardown_rx) = std::sync::mpsc::sync_channel(1); + assert!(pool.submit(Box::new(move || teardown_tx.send(()).unwrap()))); + teardown_rx.recv_timeout(Duration::from_secs(1)).expect("teardown fanout job did not run"); + let inner = Arc::downgrade(&pool.inner); + drop(pool); + let deadline = Instant::now() + Duration::from_secs(1); + while inner.upgrade().is_some() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(inner.upgrade().is_none(), "dropped fanout pool retained its executor state"); + } + + #[test] + fn cell_pixel_fanout_returns_when_an_operation_ignores_its_deadline() { + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let caller_gate = gate.clone(); + let caller = std::thread::spawn(move || { + let pool = DeadlineFanoutPool::new(); + let items = vec![1_u8]; + let deadline = Instant::now() + Duration::from_millis(30); + let results = bounded_deadline_map(&pool, &items, deadline, move |item, _| { + let (released, changed) = &*caller_gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + *item + }); + let _ = sender.send(results); }); - let projection = public_request( - &mux, - "projection", - "frontend_projection.put", - projection_params.clone(), - Some("auxiliary-restart-projection"), - ); - let projection_value = projection["result"]["value"].clone(); - let before = crate::resource_api::public_session_snapshot(&mux).unwrap(); - assert!(before["notifications"].as_array().unwrap().contains(¬ification_value)); - assert!(before["agents"].as_array().unwrap().contains(&agent_value)); - assert!(before["frontend_projections"].as_array().unwrap().contains(&projection_value)); - let durable_colors = mux.default_colors(); - assert_eq!(defaults_value["foreground"], "#123456"); - assert_eq!(defaults_value["background"], "#654321"); - assert_eq!(defaults_value["cursor_style"], "bar"); - assert_eq!(defaults_value["cursor_blink"], true); - assert_eq!(defaults_value["palette"]["1"], "#abcdef"); - mux.shutdown(); - drop(mux); + let returned_before_release = receiver.recv_timeout(Duration::from_millis(150)).is_ok(); + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + caller.join().unwrap(); - let registry = WorkspaceRegistry::open(&root, session).unwrap(); - let reopened = Mux::from_workspace_registry( - session.into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); - assert_eq!(reopened.default_colors(), durable_colors); - let config_colors = DefaultColors { - fg: Some(crate::Rgb { r: 0xee, g: 0xdd, b: 0xcc }), - ..Default::default() - }; - reopened.seed_default_colors_if_no_durable_override(config_colors); - assert_eq!( - reopened.default_colors(), - durable_colors, - "startup config must not overwrite an explicit durable update" + assert!( + returned_before_release, + "fanout joined an operation after its shared deadline elapsed" ); + } - let after = crate::resource_api::public_session_snapshot(&reopened).unwrap(); - assert!(after["notifications"].as_array().unwrap().contains(¬ification_value)); - assert!(after["agents"].as_array().unwrap().contains(&agent_value)); - assert!(after["frontend_projections"].as_array().unwrap().contains(&projection_value)); - let terminals = after["terminals"].as_array().unwrap(); - assert_eq!(terminals.len(), 1); - let terminal = &terminals[0]; - assert_eq!(terminal["id"], terminal_public_id.as_str()); - assert_eq!(terminal["lifecycle"], "exited"); - assert_eq!(terminal["exit"]["outcome"]["kind"], "unknown"); - assert_eq!(terminal["exit"]["outcome"]["reason"], "missing-host-record"); - assert_eq!(reopened.resource_surface_for_terminal(&terminal_public_id), None); - let exited = - reopened.wait_for_terminal_exit(&terminal_public_id, Some(Duration::ZERO)).unwrap(); - assert_eq!(exited["state"], "exited"); - assert_eq!(exited["outcome"]["kind"], "unknown"); - assert_eq!(exited["outcome"]["reason"], "missing-host-record"); - let notifications = public_request( - &reopened, - "notifications", - "notification.list", - selectors.clone(), - None, - ); - assert_eq!(notifications["result"], serde_json::json!([notification_value])); - let agents = public_request(&reopened, "agents", "agent.list", selectors.clone(), None); - assert_eq!(agents["result"], serde_json::json!([agent_value])); - let snapshot = public_request(&reopened, "snapshot", "session.snapshot", selectors, None); - assert_eq!(snapshot["result"]["notifications"], after["notifications"]); - assert_eq!(snapshot["result"]["agents"], after["agents"]); - assert_eq!(snapshot["result"]["frontend_projections"], after["frontend_projections"]); + #[test] + fn timed_out_cell_pixel_failure_is_retried_after_the_worker_finishes() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(20)); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move |surface, target, _deadline| { + if attempts.fetch_add(1, Ordering::AcqRel) == 0 { + std::thread::sleep(Duration::from_millis(60)); + anyhow::bail!("injected late cell-pixel failure"); + } + surface.set_cell_pixel_size(target.0, target.1).map(|changed| changed.then_some(0)) + } + })); - for (id, operation, params, key, original) in [ - ( - "create-replay", - "workspace.create", - create_params, - "auxiliary-restart-create", - created_value, - ), - ( - "notification-replay", - "notification.create", - notification_params, - "auxiliary-restart-notification", - notification_value, - ), - ("agent-replay", "agent.report", agent_params, "auxiliary-restart-agent", agent_value), - ( - "defaults-replay", - "session.terminal_defaults.update", - defaults_params, - "auxiliary-restart-defaults", - defaults_value, - ), - ( - "projection-replay", - "frontend_projection.put", - projection_params, - "auxiliary-restart-projection", - projection_value, - ), - ] { - let replay = public_request(&reopened, id, operation, params, Some(key)); - assert_eq!(replay["result"]["value"], original, "{operation}"); - assert_eq!(replay["result"]["replayed"], true, "{operation}"); + let update = mux.set_cell_pixel_size(9, 18); + assert_eq!(update.failures.len(), 1); + assert!(update.failures[0].deferred); + + let deadline = Instant::now() + Duration::from_secs(1); + while mux.cell_pixel_size() != (9, 18) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); } - assert_eq!(reopened.resource_notifications(256).len(), 1); assert!( - reopened.list_agents(None, None).is_empty(), - "the legacy live-surface cache must not retain a detached terminal" + attempts.load(Ordering::Acquire) >= 2, + "the failed operation that finished after the shared deadline was never retried" + ); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + } + + #[test] + fn cell_pixel_fanout_retries_the_same_metric_before_publishing_it() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + surface.fail_next_test_master_resize(); + + let update = mux.set_cell_pixel_size(9, 18); + + assert!(update.resizes.is_empty()); + assert_eq!(update.failures.len(), 1); + assert_eq!(update.failures[0].surface, surface.id); + assert!(update.failures[0].error.contains("injected PTY master resize failure")); + assert_eq!( + mux.cell_pixel_size(), + (8, 16), + "published metric must remain at the last fully converged value" ); + assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); + assert_eq!(surface.test_cell_pixel_size(), (8, 16)); + let created_while_pending = mux.new_workspace(None, Some((80, 24))).unwrap(); + assert_eq!(created_while_pending.test_cell_pixel_size(), (8, 16)); + let master = surface.test_master_size(); assert_eq!( - reopened - .workspace_registry - .lock() - .unwrap() - .public_frontend_projections() - .unwrap() - .len(), - 1 + (master.cols, master.rows, master.pixel_width, master.pixel_height), + (80, 24, 640, 384) + ); + + let retried = mux.set_cell_pixel_size(9, 18); + assert!(retried.failures.is_empty()); + assert_eq!( + retried.resizes, + vec![(surface.id, (80, 24), 0), (created_while_pending.id, (80, 24), 0)] ); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + } + + #[test] + fn cell_pixel_fanout_retries_work_skipped_after_the_shared_deadline() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + for _ in 1..=CELL_PIXEL_FANOUT_MAX_WORKERS { + mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + } + let mut surface_ids = + mux.state.lock().unwrap().surfaces.keys().copied().collect::>(); + surface_ids.sort_unstable(); + assert_eq!(surface_ids.len(), CELL_PIXEL_FANOUT_MAX_WORKERS + 1); + let last_surface = *surface_ids.last().unwrap(); + let last_attempts = Arc::new(AtomicUsize::new(0)); + *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(50)); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ + let last_attempts = last_attempts.clone(); + move |surface, target, deadline| { + if surface.id != last_surface { + std::thread::sleep( + deadline.saturating_duration_since(Instant::now()) + + Duration::from_millis(10), + ); + } else { + last_attempts.fetch_add(1, Ordering::AcqRel); + if Instant::now() >= deadline { + return Err( + crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed.into() + ); + } + } + surface.set_cell_pixel_size(target.0, target.1).map(|changed| changed.then_some(0)) + } + })); - reopened.shutdown(); - drop(reopened); + let update = mux.set_cell_pixel_size(9, 18); - let registry = WorkspaceRegistry::open(&root, session).unwrap(); - registry.insert_corrupt_terminal_defaults_for_test(); - let error = Mux::from_workspace_registry( - session.into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .err() - .expect("corrupt durable terminal defaults must fail startup"); - assert!( - error.to_string().contains("omitted background"), - "unexpected startup error: {error:#}" + // The first worker wave finishes just after the shared deadline. Its + // completion callbacks may reconcile those surfaces before this call + // returns, depending on scheduler timing. The unscheduled final + // surface must remain deferred in either ordering. + assert!(update.failures.iter().any(|failure| failure.surface == last_surface)); + assert!(update.failures.iter().all(|failure| failure.deferred)); + let retry_deadline = Instant::now() + Duration::from_secs(1); + while mux.cell_pixel_size() != (9, 18) && Instant::now() < retry_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + mux.cell_pixel_size(), + (9, 18), + "work that missed the first worker wave was never reconciled" ); - std::fs::remove_dir_all(root).unwrap(); + assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + assert_eq!(last_attempts.load(Ordering::Acquire), 1); } #[test] - fn resource_layout_undo_confirmation_is_restart_safe() { - let root = std::env::temp_dir() - .join(format!("cmux-resource-undo-restart-{}", WorkspacePublicId::random().unwrap())); - let (fixture_snapshot, fixture_topology) = resource_restore_fixture(); - { - let mut registry = WorkspaceRegistry::open(&root, "undo-restart").unwrap(); - registry - .commit_resource_patch( - &WorkspaceMutation::new("seed-undo-restart", "test").unwrap(), - "session.restore_fixture", - &serde_json::json!({"fixture":"layout-undo-restart"}), - None, - Some(0), - &resource_restore_patch(&fixture_snapshot, &fixture_topology), - &serde_json::json!({"restored":true}), - &serde_json::json!([{"event":"session.restored"}]), - ) - .unwrap(); - } + fn cell_pixel_deadline_retries_stop_and_report_terminal_failure() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(10)); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move |_, _, _| { + attempts.fetch_add(1, Ordering::AcqRel); + Err(crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed.into()) + } + })); + let events = mux.subscribe(); - let registry = WorkspaceRegistry::open(&root, "undo-restart").unwrap(); - let mux = Mux::from_workspace_registry( - "undo-restart".into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); - let base_pane = mux.with_state(|state| state.workspaces[0].screens[0].active_pane); - let created_terminal = mux.new_pane_right(base_pane, 0.5, Some((38, 22))).unwrap(); - let created_pane = mux.with_state(|state| state.pane_of(created_terminal.id).unwrap()); - let created = mux - .new_browser_tab( - "about:blank#layout-undo-restart".into(), - Some(created_pane), - Some((38, 22)), - ) - .unwrap(); - assert!(mux.close_surface(created_terminal.id).unwrap()); - drop(created_terminal); - let (selectors, screen_id) = { - let registry = mux.workspace_registry.lock().unwrap(); - let state = mux.state.lock().unwrap(); - let (workspace, screen) = state.screen_of(created_pane).unwrap(); - ( - crate::ResourceSelectors { - machine: Some(registry.machine_id().to_string()), - session: Some(registry.session_id().to_string()), - workspace: Some(state.workspaces[workspace].public_id.to_string()), - screen: Some(state.workspaces[workspace].screens[screen].public_id.to_string()), - ..crate::ResourceSelectors::default() - }, - state.workspaces[workspace].screens[screen].public_id.clone(), - ) - }; - let preview_fields = - serde_json::json!({"confirm_close":false}).as_object().unwrap().clone(); - let durable_before = - mux.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); - let preview_error = mux - .resource_topology_operation( - ResourceOperation::ScreenLayoutUndo, - selectors.clone(), - preview_fields, - Some(durable_before.revision), - &WorkspaceMutation::new("restart-undo-preview", "test").unwrap(), - ) - .unwrap_err(); - let preview_revision = preview_error - .downcast_ref::() - .and_then(|error| error.details["revision"].as_str()) - .and_then(|revision| revision.parse::().ok()) - .unwrap(); - let confirmation_token = preview_error - .downcast_ref::() - .and_then(|error| error.details["confirmation_token"].as_str()) - .unwrap() - .to_string(); - assert_eq!(preview_revision, durable_before.revision); - drop(created); - mux.shutdown(); - let shutdown_deadline = Instant::now() + Duration::from_secs(10); - while Arc::strong_count(&mux) > 1 && Instant::now() < shutdown_deadline { + let update = mux.set_cell_pixel_size(9, 18); + + assert_eq!(update.failures.len(), 1); + assert!(update.failures[0].deferred); + let deadline = Instant::now() + Duration::from_secs(1); + while mux.cell_pixel_retries.lock().unwrap().worker_running && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(10)); } + assert!( + !mux.cell_pixel_retries.lock().unwrap().worker_running, + "deadline retries did not stop after {} attempts", + attempts.load(Ordering::Acquire) + ); assert_eq!( - Arc::strong_count(&mux), - 1, - "restored browser bootstrap workers must release the mux before restart" + attempts.load(Ordering::Acquire), + usize::from(CELL_PIXEL_RETRY_MAX_ATTEMPTS) + 1 ); - drop(mux); + assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); + assert!(events.try_iter().any(|event| matches!( + event, + MuxEvent::GraphicsStatus(GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts: CELL_PIXEL_RETRY_MAX_ATTEMPTS, + remaining: 1, + cell_pixels: (9, 18), + }) + ))); - let registry = WorkspaceRegistry::open(&root, "undo-restart").unwrap(); - let reopened = Mux::from_workspace_registry( - "undo-restart".into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); - let reopened_before = - reopened.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); - // Restart may reconcile terminal liveness into a later public - // revision. The confirmation token is fenced by generation, screen, - // layout revision, and pane/tab membership rather than that unrelated - // liveness revision. - assert!(reopened_before.revision >= durable_before.revision); - assert_eq!(reopened_before.screens, durable_before.screens); - assert_eq!(reopened_before.panes, durable_before.panes); - assert_eq!(reopened_before.tabs, durable_before.tabs); - assert!(reopened_before.screens.iter().any(|screen| screen.public_id == screen_id)); - let state_before = reopened.with_state(state_topology_fingerprint); - let confirm_fields = serde_json::json!({ - "confirm_close":true, - "confirmation_token":confirmation_token, - }) - .as_object() - .unwrap() - .clone(); - let confirm_fingerprint = serde_json::json!({ - "operation":"screen.layout.undo", - "selectors":selectors, - "fields":confirm_fields, - }); - let confirm_mutation = WorkspaceMutation::new("restart-undo-confirm", "test").unwrap(); + assert!(surface.set_cell_pixel_size(9, 18).unwrap()); + mux.reconcile_deferred_cell_pixel_ack(surface.id, (9, 18)); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + } - let error = reopened - .resource_topology_operation( - ResourceOperation::ScreenLayoutUndo, - selectors, - confirm_fields, - Some(reopened_before.revision), - &confirm_mutation, - ) - .unwrap_err(); + #[test] + fn late_cell_pixel_ack_publishes_the_pending_creation_metric() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + surface.fail_next_test_master_resize(); - assert!(matches!( - error.downcast_ref::(), - Some(LayoutUndoError::Unavailable) - )); - assert_eq!(reopened.with_state(state_topology_fingerprint), state_before); - let registry = reopened.workspace_registry.lock().unwrap(); - assert_eq!(registry.resource_topology_snapshot().unwrap(), reopened_before); - assert!( - registry.resource_events_after(reopened_before.revision).unwrap().batches.is_empty() - ); - assert!( - registry - .lookup_resource_effect( - &confirm_mutation.id, - "screen.layout.undo", - &confirm_fingerprint, - ) - .unwrap() - .is_none() - ); - drop(registry); - reopened.shutdown(); - drop(reopened); - std::fs::remove_dir_all(root).unwrap(); + let update = mux.set_cell_pixel_size(9, 18); + assert_eq!(update.failures.len(), 1); + assert_eq!(mux.cell_pixel_size(), (8, 16)); + assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); + + assert!(surface.set_cell_pixel_size(9, 18).unwrap()); + mux.reconcile_deferred_cell_pixel_ack(surface.id, (9, 18)); + + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(mux.cell_pixel_creation_size(), (9, 18)); + assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + } + + #[test] + fn deferred_cell_pixel_target_sizes_new_surfaces_before_the_ack_arrives() { + let mux = test_mux(); + let pending_surface = 99_999; + *mux.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { + generation: 1, + target: (9, 18), + failures: HashSet::from([pending_surface]), + use_for_creation: true, + }); + + let created = mux.new_workspace(None, Some((80, 24))).unwrap(); + + assert_eq!(mux.cell_pixel_size(), (8, 16)); + assert_eq!(created.test_cell_pixel_size(), (9, 18)); + mux.reconcile_deferred_cell_pixel_ack(pending_surface, (9, 18)); + assert_eq!(mux.cell_pixel_size(), (9, 18)); } - #[cfg(unix)] - fn insert_running_terminal_identity_surface( - mux: &Arc, - terminal_id: &str, - incarnation: &str, - workspace_key: &str, - ) -> Arc { - let surface = - mux.seed_running_terminal_for_test(terminal_id, incarnation, workspace_key).unwrap(); - mux.surface(surface).unwrap() + #[test] + fn closing_the_last_failed_terminal_publishes_pending_cell_pixels() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + *mux.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { + generation: 1, + target: (9, 18), + failures: HashSet::from([surface.id]), + use_for_creation: false, + }); + + assert_eq!(mux.cell_pixel_size(), (8, 16)); + close_terminal_runtime_for_test(&mux, &surface); + + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); } #[test] - fn terminal_registry_launch_spec_never_persists_environment_secrets() { - let sentinel = "cmux-secret-sentinel-do-not-persist"; - let options = SurfaceOptions { - command: Some(vec!["/bin/sh".into(), "-c".into(), sentinel.into()]), - cwd: Some(format!("/tmp/{sentinel}")), - extra_env: vec![ - ("API_BEARER_TOKEN".into(), sentinel.into()), - ("CMUX_TUI_SOCKET".into(), "/tmp/cmux.sock".into()), - ("CMUX_TUI_HOOK".into(), "/tmp/cmux-tui-hook".into()), - ], - ..SurfaceOptions::default() - }; - let encoded = serde_json::to_string(&terminal_launch_spec(&options)).unwrap(); - assert!(!encoded.contains(sentinel)); - assert!(!encoded.contains("API_BEARER_TOKEN")); - assert!(!encoded.contains("/tmp/cmux.sock")); - assert!(!encoded.contains("/tmp/cmux-tui-hook")); - assert!(!encoded.contains("/bin/sh")); - assert!(encoded.contains("CMUX_TUI_SOCKET")); - assert!(encoded.contains("CMUX_TUI_HOOK")); + fn failed_viewer_resize_preserves_previous_report_and_creation_default() { + let mux = test_mux(); + let missing_surface = 99_999; + mux.record_client_size(90, 30); + mux.client_sizing + .lock() + .unwrap() + .surfaces + .entry(missing_surface) + .or_default() + .insert(7, (80, 25)); + + assert!(mux.resize_surface_for_client(missing_surface, 7, 120, 40).is_err()); + assert_eq!(mux.client_surface_size(missing_surface, 7), Some((80, 25))); + assert_eq!(mux.new_workspace(None, None).unwrap().size(), (90, 30)); } #[test] - fn terminal_create_mutation_persists_only_a_secret_free_digest() { - const TERMINAL: &str = "00000000000040008000000000000009"; - let sentinel = "cmux-create-secret-sentinel-do-not-persist"; - let root = std::env::temp_dir() - .join(format!("cmux-create-fingerprint-{}", crate::workspace_registry::new_uuid_v4())); - let argv = vec!["/bin/sh".to_string(), "-c".to_string(), sentinel.to_string()]; - let cwd = format!("/tmp/{sentinel}"); - let name = format!("terminal-{sentinel}"); - let fingerprint = terminal_create_fingerprint( - "workspace-one", - Some(TERMINAL), - Some(&argv), - Some(&cwd), - Some(&name), - Some((80, 24)), - ) - .unwrap(); - assert!(!serde_json::to_string(&fingerprint).unwrap().contains(sentinel)); + fn failed_first_terminal_report_removes_empty_sizing_indexes() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + assert_eq!(mux.claim_terminal_geometry(surface.id, 0), Some(true)); + surface.fail_next_test_master_resize(); - { - let mut registry = WorkspaceRegistry::open(&root, "secret-test").unwrap(); - registry - .commit( - &WorkspaceMutation::new("workspace", "test").unwrap(), - &serde_json::json!({"op":"create-workspace"}), - None, - Some(0), - "workspace-added", - "workspace-one", - &[RegistryWorkspace { - id: 1, - public_id: WorkspacePublicId::random().unwrap(), - key: "workspace-one".into(), - name: "One".into(), - group_key: "secret-test".into(), - }], - &serde_json::json!({"workspace":1,"key":"workspace-one"}), - ) - .unwrap(); - registry - .commit_terminal( - &WorkspaceMutation::new("create", "browser").unwrap(), - &fingerprint, - None, - Some(0), - "terminal-reserved", - &RegistryTerminal { - terminal_id: TERMINAL.into(), - workspace_key: "workspace-one".into(), - incarnation: None, - lifecycle: TerminalLifecycle::Launching, - launch_spec: serde_json::json!({"command_present":true}), - exit: None, - }, - &serde_json::json!({"terminal_id":TERMINAL}), - ) - .unwrap(); - } + assert!(mux.resize_surface_for_client(surface.id, 0, 100, 30).is_err()); + let sizing = mux.client_sizing.lock().unwrap(); + assert!(!sizing.surfaces.contains_key(&surface.id)); + assert!(!sizing.report_order.contains_key(&(surface.id, 0))); + assert!(!sizing.terminal_runtime_by_placement.contains_key(&surface.id)); + } - fn assert_tree_does_not_contain(path: &Path, needle: &[u8]) { - for entry in std::fs::read_dir(path).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - assert_tree_does_not_contain(&path, needle); - } else { - let bytes = std::fs::read(&path).unwrap(); - assert!( - !bytes.windows(needle.len()).any(|window| window == needle), - "secret persisted in {}", - path.display() - ); + #[test] + fn concurrent_browser_viewer_reports_settle_at_the_shared_minimum() { + let mux = test_mux(); + let surface = mux + .new_browser_tab("about:blank#concurrent-sizing".into(), None, Some((100, 30))) + .unwrap(); + let surface_id = surface.id; + let pause_first = Arc::new(AtomicBool::new(true)); + let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(1); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let hook_release = release.clone(); + mux.set_client_resize_before_apply(Some(Arc::new(move || { + if pause_first.swap(false, Ordering::SeqCst) { + reached_tx.send(()).unwrap(); + let (lock, ready) = &*hook_release; + let mut released = lock.lock().unwrap(); + while !*released { + released = ready.wait(released).unwrap(); } } + }))); + + let first_mux = mux.clone(); + let first = std::thread::spawn(move || { + first_mux.resize_surface_for_client(surface_id, 1, 120, 40).unwrap(); + }); + reached_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let second_mux = mux.clone(); + let second = std::thread::spawn(move || { + second_mux.resize_surface_for_client(surface_id, 2, 80, 50).unwrap(); + }); + let (lock, ready) = &*release; + *lock.lock().unwrap() = true; + ready.notify_all(); + first.join().unwrap(); + second.join().unwrap(); + + if let Some(pending) = surface.pending_resize_completion(80, 40).unwrap() { + assert_eq!(pending.completion.recv_timeout(Duration::from_secs(10)).unwrap(), Ok(())); } - assert_tree_does_not_contain(&root, sentinel.as_bytes()); - std::fs::remove_dir_all(root).unwrap(); + assert_eq!(surface.size(), (80, 40)); + mux.set_client_resize_before_apply(None); + let _ = mux.shutdown(); } - #[test] - fn stable_terminal_lookup_never_chooses_between_duplicate_ids() { - let terminal_id = "00112233445566778899aabbccddeeff"; - let identities = vec![ - ( - 10, - TerminalHostIdentity { - terminal_id: terminal_id.into(), - incarnation: "11111111111111111111111111111111".into(), - }, - ), - ( - 20, - TerminalHostIdentity { - terminal_id: terminal_id.into(), - incarnation: "22222222222222222222222222222222".into(), - }, - ), - ]; - - assert_eq!( - unique_terminal_match(terminal_id, identities.clone()).unwrap_err().to_string(), - "duplicate_terminal_id" - ); - let unique = - unique_terminal_match(terminal_id, identities.into_iter().take(1)).unwrap().unwrap(); - assert_eq!(unique.0, 10); + fn projected_terminal_view(mux: &Arc, source: &Arc) -> Arc { + let terminal_id = source + .terminal_public_id() + .cloned() + .expect("test terminal has a public content identity"); + let pane = mux.with_state(|state| state.pane_of(source.id).unwrap()); + mux.resource_project_terminal_selected( + crate::ResourceSelectors { + terminal: Some(terminal_id.to_string()), + ..Mux::ordinary_resource_selectors() + }, + mux.ordinary_pane_selectors(pane).unwrap(), + usize::MAX, + None, + None, + &WorkspaceMutation::local("test-terminal-projection"), + ) + .unwrap(); + mux.with_state(|state| { + state + .placements_of_content(&ContentPublicId::Terminal(terminal_id)) + .iter() + .copied() + .find(|placement| *placement != source.id) + .and_then(|placement| state.surfaces.get(&placement)) + .cloned() + .expect("projected terminal view is materialized") + }) } #[test] - fn cell_pixel_metric_publishes_only_after_existing_surface_fanout() { + fn projected_views_share_one_graphics_and_cell_pixel_owner() { let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - let observed_at_publish = Arc::new(Mutex::new(Vec::new())); - *mux.cell_pixel_before_publish.lock().unwrap() = Some(Arc::new({ - let observed_at_publish = observed_at_publish.clone(); - move |metric| observed_at_publish.lock().unwrap().push(metric) - })); + let source = mux.new_workspace(None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + assert_eq!(mux.kitty_image_budget.lock().unwrap().entries.len(), 1); - let update = mux.set_cell_pixel_size(9, 18); + let projected = projected_terminal_view(&mux, &source); + wait_for_kitty_image_budget(&mux); + assert!(source.shares_terminal_runtime(&projected)); + assert_eq!(mux.kitty_image_budget.lock().unwrap().entries.len(), 1); - assert_eq!(*observed_at_publish.lock().unwrap(), vec![(8, 16)]); - assert_eq!(mux.cell_pixel_size(), (9, 18)); - assert_eq!(surface.test_cell_pixel_size(), (9, 18)); - assert_eq!(update.resizes, vec![(surface.id, (80, 24), 0)]); + let applications = Arc::new(AtomicUsize::new(0)); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ + let applications = applications.clone(); + move |surface, target, _deadline| { + applications.fetch_add(1, Ordering::AcqRel); + surface.set_cell_pixel_size(target.0, target.1).map(|changed| changed.then_some(0)) + } + })); + let update = mux.set_cell_pixel_size(9, 18); assert!(update.failures.is_empty()); + assert_eq!(applications.load(Ordering::Acquire), 1); + assert_eq!(source.test_cell_pixel_size(), (9, 18)); + assert_eq!(projected.test_cell_pixel_size(), (9, 18)); + + assert!(mux.close_surface(source.id).unwrap()); + wait_for_kitty_image_budget(&mux); + assert_eq!(mux.kitty_image_budget.lock().unwrap().entries.len(), 1); + + close_terminal_runtime_for_test(&mux, &source); + wait_for_kitty_image_budget(&mux); + assert!(mux.kitty_image_budget.lock().unwrap().entries.is_empty()); } #[test] - fn unchanged_cell_pixel_result_must_match_the_requested_metric() { + fn passive_terminal_viewports_never_resize_the_canonical_grid() { let mux = test_mux(); let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new(|_, _, _| Ok(None))); - let update = mux.set_cell_pixel_size(9, 18); + assert!(!mux.resize_surface_for_client(surface.id, 0, 120, 40).unwrap()); + assert_eq!(surface.size(), (80, 24)); + assert_eq!(mux.client_surface_size(surface.id, 0), Some((120, 40))); + assert!(!mux.client_size_participates(surface.id, 0)); - assert!(update.resizes.is_empty()); - assert_eq!(update.failures.len(), 1); - assert_eq!(update.failures[0].surface, surface.id); - assert!( - update.failures[0].error.contains("did not converge"), - "unexpected failure: {}", - update.failures[0].error - ); - assert_eq!(mux.cell_pixel_size(), (8, 16)); - assert_eq!(surface.test_cell_pixel_size(), (8, 16)); + assert_eq!(mux.claim_terminal_geometry(surface.id, 0), Some(true)); + assert_eq!(surface.size(), (120, 40)); + assert!(mux.client_size_participates(surface.id, 0)); + + assert!(!mux.resize_surface_for_client(surface.id, 7, 60, 20).unwrap()); + assert_eq!(surface.size(), (120, 40)); + assert_eq!(mux.client_surface_size(surface.id, 7), Some((60, 20))); + assert!(!mux.client_size_participates(surface.id, 7)); } #[test] - fn terminal_spawn_releases_cell_pixel_lifecycle_and_reconciles_before_publish() { + fn geometry_authority_moves_between_views_of_one_terminal() { let mux = test_mux(); - let observed_unlocked = Arc::new(Mutex::new(Vec::new())); - *mux.terminal_spawn_after_cell_pixel_snapshot.lock().unwrap() = Some(Arc::new({ - let mux = Arc::downgrade(&mux); - let observed_unlocked = observed_unlocked.clone(); - move |unlocked| { - observed_unlocked.lock().unwrap().push(unlocked); - if unlocked { - mux.upgrade().unwrap().set_cell_pixel_size(9, 18); - } - } - })); + let source = mux.new_workspace(None, Some((80, 24))).unwrap(); + let projected = projected_terminal_view(&mux, &source); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + assert!(source.shares_terminal_runtime(&projected)); + assert!(!mux.resize_surface_for_client(source.id, 0, 110, 35).unwrap()); + assert!(!mux.resize_surface_for_client(projected.id, 0, 60, 20).unwrap()); - assert_eq!(*observed_unlocked.lock().unwrap(), vec![true]); - assert_eq!(mux.cell_pixel_size(), (9, 18)); - assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + assert_eq!(mux.claim_terminal_geometry(source.id, 0), Some(true)); + assert_eq!(source.size(), (110, 35)); + assert_eq!(projected.size(), (110, 35)); + + assert_eq!(mux.claim_terminal_geometry(projected.id, 0), Some(true)); + assert_eq!(source.size(), (60, 20)); + assert_eq!(projected.size(), (60, 20)); + assert!(!mux.client_size_participates(source.id, 0)); + assert!(mux.client_size_participates(projected.id, 0)); } #[test] - fn kitty_image_storage_and_copied_pixels_share_one_process_budget() { + fn terminal_runtime_events_fan_out_only_to_materialized_views() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut surfaces = vec![first]; - for _ in 1..8 { - surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); - } - wait_for_kitty_image_budget(&mux); + let source = mux.new_workspace(None, Some((80, 24))).unwrap(); + let projected = projected_terminal_view(&mux, &source); + let events = mux.subscribe(); + let expected = HashSet::from([source.id, projected.id]); - let configured = surfaces - .iter() - .map(|surface| { - surface.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap() - }) - .map(|limits| { - limits - .image_bytes - .saturating_mul(KITTY_IMAGE_PERSISTENT_COPIES_PER_SURFACE) - .saturating_add(limits.inflight_bytes) - }) - .sum::(); + let collect_two = |map: fn(MuxEvent) -> Option| { + (0..2) + .map(|_| events.recv_timeout(Duration::from_secs(1)).unwrap()) + .filter_map(map) + .collect::>() + }; - assert!( - configured <= KITTY_IMAGE_PROCESS_BUDGET_BYTES, - "per-terminal limits allow {configured} bytes across native screen storage, copied caches, and in-flight uploads" + mux.emit_terminal_output(source.id); + assert_eq!( + collect_two(|event| match event { + MuxEvent::SurfaceOutput(surface) => Some(surface), + _ => None, + }), + expected ); + mux.emit_terminal_title(source.id, Arc::from("shared title")); + assert_eq!( + collect_two(|event| match event { + MuxEvent::TitleChanged { surface, .. } => Some(surface), + _ => None, + }), + expected + ); + mux.emit_terminal_bell(source.id); + assert_eq!( + collect_two(|event| match event { + MuxEvent::Bell(surface) => Some(surface), + _ => None, + }), + expected + ); + mux.emit_terminal_resized(source.id, 100, 30, Some(7)); + assert_eq!( + collect_two(|event| match event { + MuxEvent::SurfaceResized { surface, .. } => Some(surface), + _ => None, + }), + expected + ); + mux.emit_terminal_scroll(source.id, 4, false); + assert_eq!( + collect_two(|event| match event { + MuxEvent::ScrollChanged { surface, .. } => Some(surface), + _ => None, + }), + expected + ); + mux.emit_terminal_exited(source.id); + assert_eq!( + collect_two(|event| match event { + MuxEvent::SurfaceExited(surface) => Some(surface), + _ => None, + }), + expected + ); + + mux.set_default_colors(DefaultColors { + fg: Some(crate::Rgb { r: 0x11, g: 0x22, b: 0x33 }), + ..Default::default() + }); + assert_eq!( + collect_two(|event| match event { + MuxEvent::SurfaceOutput(surface) => Some(surface), + _ => None, + }), + expected + ); + assert!(matches!(events.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))); + + mux.remove_surface_runtime_for_test(source.id).unwrap(); + mux.remove_surface_runtime_for_test(projected.id).unwrap(); + mux.emit_terminal_output(source.id); + assert!(matches!(events.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))); } + #[cfg(unix)] #[test] - fn kitty_capacity_buckets_reserve_encoded_upload_bytes_inside_the_process_budget() { - let mut capacity = 1; - while capacity <= KITTY_IMAGE_BUDGET_OWNER_LIMIT { - let limits = kitty_image_limits_for_capacity(capacity); - assert_eq!( - limits.inflight_bytes, - ghostty_vt::kitty_inflight_replay_limit_for_image_bytes(limits.image_bytes) + fn hosted_terminal_exit_atomically_detaches_every_projected_view() { + let mux = test_mux(); + let source = mux.new_workspace(None, Some((80, 24))).unwrap(); + let projected = projected_terminal_view(&mux, &source); + let terminal_id = source + .terminal_public_id() + .cloned() + .expect("hosted terminal has a public content identity"); + let host = mux + .resource_terminal_host_identity(&source) + .expect("hosted terminal has a durable process identity"); + let runtime_id = source.terminal_runtime_id().expect("hosted terminal has a runtime"); + let placements = HashSet::from([source.id, projected.id]); + let tab_ids = mux.with_state(|state| { + placements + .iter() + .map(|surface| state.resource_indexes.tab_ids[surface].clone()) + .collect::>() + }); + let before_revision = mux.workspace_registry.lock().unwrap().resource_revision().unwrap(); + let events = mux.subscribe(); + + mux.surface_exited(source.id); + + mux.with_state(|state| { + assert!( + state + .placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())) + .is_empty(), + "an exited runtime retained a projected view" ); assert!( - kitty_surface_byte_reservation(limits.image_bytes).saturating_mul(capacity as u64) - <= KITTY_IMAGE_PROCESS_BUDGET_BYTES, - "capacity {capacity} exceeded the process byte budget with {limits:?}" + !state.terminal_catalog.contains_key(&terminal_id), + "an exited runtime remained catalog-owned" ); - capacity = capacity.saturating_mul(2); + assert!( + !state.terminal_catalog_by_runtime.contains_key(&runtime_id), + "an exited runtime retained its reverse catalog entry" + ); + }); + let resolved = mux.resolve_terminal(&host.terminal_id).unwrap().unwrap(); + assert_eq!(resolved.surface, None); + assert_eq!(resolved.terminal.lifecycle, TerminalLifecycle::Exited); + let waited = mux.wait_for_terminal_exit(&terminal_id, Some(Duration::ZERO)).unwrap(); + assert_eq!(waited["state"], "exited"); + + let batches = mux.resource_events_after(before_revision).unwrap().batches; + assert_eq!(batches.len(), 1, "exit lifecycle and topology split across revisions"); + let changes = batches[0].changes.as_array().unwrap(); + for tab_id in tab_ids { + assert!( + changes.iter().any(|change| { + change["kind"] == "delete" + && change["resource"] == "tab" + && change["id"] == tab_id.as_str() + }), + "atomic exit omitted projected tab {tab_id}" + ); + } + + let deadline = Instant::now() + Duration::from_secs(1); + let mut exited = HashSet::new(); + while exited != placements { + assert!(Instant::now() < deadline, "not every projected view observed terminal exit"); + if let Ok(MuxEvent::SurfaceExited(surface)) = + events.recv_timeout(Duration::from_millis(20)) + { + exited.insert(surface); + } } + let _ = mux.shutdown(); } - #[cfg(unix)] #[test] - fn exited_terminal_placeholders_do_not_consume_kitty_quota() { + fn receipted_creation_atomically_uses_client_local_fallback_and_replays_it() { let mux = test_mux(); - let opts = mux.surface_options.lock().unwrap().clone(); - let live = - Surface::spawn_for_test(mux.next_id(), opts.clone(), Arc::downgrade(&mux)).unwrap(); - wait_for_kitty_image_budget(&mux); - let unconstrained = - live.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(); + let fallback = mux.new_workspace(None, Some((80, 24))).unwrap(); + let fallback_pane = mux.with_state(|state| state.pane_of(fallback.id).unwrap()); + let primary = mux.split(fallback_pane, SplitDir::Right, Some((40, 24))).unwrap(); + let primary_pane = mux.with_state(|state| state.pane_of(primary.id).unwrap()); + let candidates = vec![ + mux.resource_selectors_for_pane(Some(primary_pane)).unwrap(), + mux.resource_selectors_for_pane(Some(fallback_pane)).unwrap(), + ]; + assert!(mux.close_pane(primary_pane).unwrap()); - let placeholder = Surface::exited_terminal_placeholder( - mux.next_id(), - opts, - Arc::downgrade(&mux), - TerminalHostIdentity { - terminal_id: "00112233445566778899aabbccddeeff".into(), - incarnation: "11111111111111111111111111111111".into(), - }, + let mutation = WorkspaceMutation::new( + "selector-fallback-receipt-00000001".to_string(), + "selector-fallback-test".to_string(), ) .unwrap(); - wait_for_kitty_image_budget(&mux); + let fields = Map::from_iter([ + ("direction".to_string(), serde_json::json!("right")), + ("cols".to_string(), serde_json::json!(40)), + ("rows".to_string(), serde_json::json!(24)), + ]); + let (created, replayed) = mux + .receipted_surface_creation( + ResourceOperation::PaneSplit, + candidates.clone(), + fields.clone(), + &mutation, + ) + .unwrap(); + assert!(!replayed); + assert_ne!(created, fallback.id); + assert!(mux.with_state(|state| state.pane_of(created).is_some())); - assert_eq!( - live.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(), - unconstrained, - "an exited placeholder reduced a live terminal's graphics share" - ); - assert_eq!( - placeholder - .with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()) - .unwrap(), - KittyGraphicsLimits::disabled(), - "an exited placeholder retained graphics resources it cannot use" - ); + assert!(mux.close_pane(fallback_pane).unwrap()); + let (replayed_surface, replayed) = mux + .receipted_surface_creation(ResourceOperation::PaneSplit, candidates, fields, &mutation) + .unwrap(); + assert!(replayed); + assert_eq!(replayed_surface, created); + mux.close_surface(created).unwrap(); } #[test] - fn kitty_object_limits_cover_primary_and_alternate_screens() { + fn receipted_creation_rejects_fallbacks_that_drop_to_backend_selection() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut surfaces = vec![first]; - for _ in 1..8 { - surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); - } - wait_for_kitty_image_budget(&mux); - let (configured_images, configured_placements) = surfaces - .iter() - .map(|surface| { - surface - .with_terminal(|terminal| { - let primary_images = terminal.kitty_image_count_limit().unwrap(); - let primary_placements = terminal.kitty_placement_count_limit().unwrap(); - terminal.vt_write(b"\x1b[?1049h"); - let alternate_images = terminal.kitty_image_count_limit().unwrap(); - let alternate_placements = terminal.kitty_placement_count_limit().unwrap(); - terminal.vt_write(b"\x1b[?1049l"); - ( - primary_images.saturating_add(alternate_images), - primary_placements.saturating_add(alternate_placements), - ) - }) - .unwrap() - }) - .fold((0u64, 0u64), |(images, placements), candidate| { - (images.saturating_add(candidate.0), placements.saturating_add(candidate.1)) - }); - assert!( - configured_images <= 4_096, - "primary and alternate screens allow {configured_images} native image records process-wide" - ); - assert!( - configured_placements <= 16_384, - "primary and alternate screens allow {configured_placements} native placements process-wide" - ); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + let pane_selectors = mux.resource_selectors_for_pane(Some(pane)).unwrap(); + let session_selectors = crate::ResourceSelectors { + machine: pane_selectors.machine.clone(), + session: pane_selectors.session.clone(), + ..crate::ResourceSelectors::default() + }; + let mutation = WorkspaceMutation::new( + "selector-fallback-receipt-00000002".to_string(), + "selector-fallback-test".to_string(), + ) + .unwrap(); + + let error = mux + .receipted_surface_creation( + ResourceOperation::TabCreateTerminal, + vec![pane_selectors, session_selectors], + Map::new(), + &mutation, + ) + .unwrap_err(); + + assert_eq!(error.to_string(), "creation selector fallbacks require pane selectors"); + assert_eq!(mux.with_state(|state| state.surfaces.len()), 1); + mux.close_surface(surface.id).unwrap(); } + #[cfg(unix)] #[test] - fn kitty_inflight_uploads_share_the_process_byte_budget() { + fn failed_atomic_exit_retries_without_exposing_partial_topology() { + const TERMINAL: &str = "0000000000004000800000000000003f"; + const INCARNATION: &str = "1000000000004000800000000000003f"; let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut surfaces = vec![first]; - for _ in 1..8 { - surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); - } - wait_for_kitty_image_budget(&mux); - let per_surface_limit = surfaces[0] - .with_terminal(|terminal| terminal.kitty_inflight_storage_limit()) - .unwrap() as usize; - let segment_bytes = (per_surface_limit.saturating_mul(3) / 5) / 4 * 4; - let first = - format!("\x1b_Ga=t,t=d,f=24,i=991,s=1,v=1,m=1,q=2;{}\x1b\\", "A".repeat(segment_bytes)); - let second = format!("\x1b_Ga=t,t=d,f=24,i=991,m=1,q=2;{}", "A".repeat(segment_bytes)); - let inflight_is_bounded = surfaces[0] - .with_terminal(|terminal| { - terminal.vt_write(first.as_bytes()); - terminal.vt_write(second.as_bytes()); - terminal.preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES).is_err() - }) + let workspace = mux + .create_empty_workspace( + Some("failed-atomic-exit".into()), + Some("018f6e21-7b70-7e70-8000-00000000103f".into()), + None, + ) .unwrap(); - assert!( - inflight_is_bounded, - "completed and current Kitty upload chunks retained more than one surface byte share" - ); - } - - #[test] - fn kitty_mux_quota_replays_a_maximum_size_inflight_upload() { - use base64::Engine as _; + let surface = + mux.seed_running_terminal_for_test(TERMINAL, INCARNATION, &workspace.key).unwrap(); + let public_id = + mux.workspace_registry.lock().unwrap().terminal_resource_id(TERMINAL).unwrap().unwrap(); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); - const IMAGE_WIDTH: usize = 2_500; - const IMAGE_HEIGHT: usize = 1_000; - const IMAGE_ID: u32 = 992; + mux.surface_exited(surface); - let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - wait_for_kitty_image_budget(&mux); + mux.with_state(|state| { + assert_eq!( + state.placements_of_content(&ContentPublicId::Terminal(public_id.clone())), + &[surface], + "failed transaction exposed a partial detach" + ); + assert!(state.terminal_catalog.contains_key(&public_id)); + }); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Running + ); + assert!(mux.terminal_exit_detaches.contains(TERMINAL)); - let pixels = vec![0xff; IMAGE_WIDTH * IMAGE_HEIGHT * 4]; - assert_eq!(pixels.len(), ghostty_vt::MAX_KITTY_IMAGE_BYTES); - let payload = base64::engine::general_purpose::STANDARD.encode(&pixels); - let (first_payload, final_payload) = payload.split_at(payload.len() - 4); - let first_chunk = format!( - "\x1b_Ga=t,t=d,f=32,i={IMAGE_ID},s={IMAGE_WIDTH},v={IMAGE_HEIGHT},m=1,q=2;{first_payload}\x1b\\" + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(false).unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let detached = mux.resolve_terminal(TERMINAL).unwrap().unwrap().surface.is_none(); + let retry_finished = !mux.terminal_exit_detaches.contains(TERMINAL); + if detached && retry_finished { + break; + } + assert!(Instant::now() < deadline, "atomic exit retry did not detach the terminal"); + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!( + mux.resolve_terminal(TERMINAL).unwrap().unwrap().terminal.lifecycle, + TerminalLifecycle::Exited ); - let final_chunk = format!("\x1b_Gm=0,q=2;{final_payload}\x1b\\"); - assert!(first_chunk.len() <= ghostty_vt::KITTY_INFLIGHT_REPLAY_MAX_BYTES); + assert!( + mux.terminal_exit_detaches.wait_until_finished(TERMINAL, deadline), + "terminal detach retry worker did not release its ownership" + ); + } - surface - .with_terminal(|terminal| { - let limits = terminal.kitty_graphics_limits().unwrap(); - assert_eq!(limits.image_bytes, ghostty_vt::MAX_KITTY_IMAGE_BYTES as u64); - terminal.vt_write(first_chunk.as_bytes()); - terminal - .preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) - .expect("Mux quota rejected replay for a permitted maximum-size Kitty image"); - terminal.vt_write(final_chunk.as_bytes()); - assert_eq!( - terminal - .kitty_graphics_snapshot() - .unwrap() - .image(IMAGE_ID) - .expect("maximum-size Kitty image was not admitted") - .data - .len(), - pixels.len() - ); - }) - .unwrap(); + #[test] + fn releasing_geometry_authority_freezes_the_terminal() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + + mux.resize_surface_for_client(surface.id, 0, 100, 30).unwrap(); + mux.claim_terminal_geometry(surface.id, 0).unwrap(); + assert_eq!(surface.size(), (100, 30)); + + assert_eq!(mux.release_terminal_geometry(surface.id), Some(true)); + assert!(!mux.resize_surface_for_client(surface.id, 0, 70, 20).unwrap()); + assert_eq!(surface.size(), (100, 30)); + + assert_eq!(mux.claim_terminal_geometry(surface.id, 0), Some(true)); + assert_eq!(surface.size(), (70, 20)); } #[test] - fn closing_kitty_surfaces_rebalances_the_survivors_process_share() { + fn removing_the_authoritative_viewport_freezes_the_terminal() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut surfaces = vec![first.clone()]; - for _ in 1..8 { - surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); - } - wait_for_kitty_image_budget(&mux); - let constrained = first - .with_terminal(|terminal| { - ( - terminal.kitty_image_storage_limit().unwrap(), - terminal.kitty_image_count_limit().unwrap(), - terminal.kitty_placement_count_limit().unwrap(), - ) - }) - .unwrap(); + let source = mux.new_workspace(None, Some((80, 24))).unwrap(); + let projected = projected_terminal_view(&mux, &source); - for surface in surfaces.iter().skip(1) { - close_terminal_runtime_for_test(&mux, surface); - } - wait_for_kitty_image_budget(&mux); + mux.resize_surface_for_client(projected.id, 0, 96, 28).unwrap(); + mux.claim_terminal_geometry(projected.id, 0).unwrap(); + assert_eq!(source.size(), (96, 28)); - let survivor_limit = first - .with_terminal(|terminal| { - ( - terminal.kitty_image_storage_limit().unwrap(), - terminal.kitty_image_count_limit().unwrap(), - terminal.kitty_placement_count_limit().unwrap(), - ) - }) - .unwrap(); - let expected = kitty_image_limits_for_capacity(1).image_bytes; - assert!( - survivor_limit.0 > constrained.0, - "surviving terminal kept its peak-surface quota of {} bytes", - survivor_limit.0 - ); - assert_eq!(survivor_limit.0, expected); - assert!( - survivor_limit.1 > constrained.1, - "surviving terminal kept its peak-surface image count of {}", - survivor_limit.1 - ); - assert!( - survivor_limit.2 > constrained.2, - "surviving terminal kept its peak-surface placement count of {}", - survivor_limit.2 - ); - assert_eq!(survivor_limit.1, 2_048); - assert_eq!(survivor_limit.2, 8_192); + mux.remove_surface_size_client(projected.id, 0); + assert!(!mux.resize_surface_for_client(source.id, 7, 55, 18).unwrap()); + assert_eq!(source.size(), (96, 28)); + assert!(!mux.client_size_participates(projected.id, 0)); } #[test] - fn killed_kitty_surface_cannot_block_the_next_terminal_reservation() { + fn only_authoritative_reports_seed_future_terminal_geometry() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut survivors = Vec::new(); - for _ in 1..4 { - survivors.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); - } - wait_for_kitty_image_budget(&mux); + let source = mux.new_workspace(None, Some((80, 24))).unwrap(); - let first_id = first.id; - *mux.kitty_image_budget_operation.lock().unwrap() = - Some(Arc::new(move |surface, limits, _deadline| { - if surface.id == first_id { - anyhow::bail!("a killed terminal host cannot acknowledge quota updates"); - } - surface.set_kitty_graphics_limits( - limits.image_bytes, - limits.inflight_bytes, - limits.images, - limits.placements, - ) - })); + mux.resize_surface_for_client(source.id, 0, 111, 33).unwrap(); + mux.claim_terminal_geometry(source.id, 0).unwrap(); + assert_eq!(mux.new_workspace(None, None).unwrap().size(), (111, 33)); - first.kill(); - let started = Instant::now(); - let replacement = mux - .new_tab(Some(pane), None, Some((80, 24))) - .expect("a killed surface retained quota and blocked terminal creation"); - assert!( - started.elapsed() < Duration::from_millis(250), - "terminal creation waited on a killed surface for {:?}", - started.elapsed() - ); + mux.resize_surface_for_client(source.id, 7, 50, 15).unwrap(); + assert_eq!(mux.new_workspace(None, None).unwrap().size(), (111, 33)); + } + #[test] + fn agent_reports_apply_hook_authority() { + let mux = test_mux(); + let surface = mux.new_workspace(None, None).unwrap(); + let events = mux.subscribe(); + let initial_revision = mux.with_state(|state| state.resource_revision); + let initial_epoch = mux.resource_event_epoch(); + let socket = mux + .report_agent( + surface.id, + AgentState::Working, + AgentSource::Socket, + Some("socket-session".to_string()), + ) + .unwrap(); + assert_eq!(socket.state, AgentState::Working); + assert_eq!(socket.source, AgentSource::Socket); + assert!(matches!( + events.recv_timeout(Duration::from_millis(100)), + Ok(MuxEvent::AgentChanged { + surface: event_surface, + state, + source, + session: Some(session), + .. + }) if event_surface == surface.id + && state.as_ref() == "working" + && source.as_ref() == "socket" + && session.as_ref() == "socket-session" + )); - *mux.kitty_image_budget_operation.lock().unwrap() = None; - close_terminal_runtime_for_test(&mux, &replacement); - for surface in survivors { - close_terminal_runtime_for_test(&mux, &surface); - } - close_terminal_runtime_for_test(&mux, &first); - wait_for_kitty_image_budget(&mux); + let hook = mux + .report_agent( + surface.id, + AgentState::Blocked, + AgentSource::Hook, + Some("hook-session".to_string()), + ) + .unwrap(); + assert_eq!(hook.state, AgentState::Blocked); + assert_eq!(hook.source, AgentSource::Hook); + + let ignored_socket = mux + .report_agent( + surface.id, + AgentState::Done, + AgentSource::Socket, + Some("late-socket".to_string()), + ) + .unwrap(); + assert_eq!(ignored_socket.state, AgentState::Blocked); + assert_eq!(ignored_socket.source, AgentSource::Hook); + + let filtered = mux.list_agents(Some(surface.id), Some(AgentState::Blocked)); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].session.as_deref(), Some("hook-session")); + assert!(mux.list_agents(Some(surface.id), Some(AgentState::Done)).is_empty()); + assert_eq!(mux.with_state(|state| state.resource_revision), initial_revision + 3); + assert_eq!(mux.resource_event_epoch(), initial_epoch + 3); + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); + let resource_events = mux.resource_events_after(initial_revision).unwrap(); + assert_eq!(resource_events.batches.len(), 3); + assert_eq!(resource_events.batches[0].changes[0]["value"]["source"], "socket"); + assert_eq!(resource_events.batches[1].changes[0]["value"]["source"], "hook"); + assert_eq!(resource_events.batches[2].changes[0]["value"]["source"], "hook"); + assert_eq!(resource_events.batches[2].changes[0]["value"]["state"], "blocked"); + assert_eq!( + resource_events.batches[2].changes[0]["value"]["source_session"], + "hook-session" + ); + assert!(matches!( + events.recv_timeout(Duration::from_millis(100)), + Ok(MuxEvent::AgentChanged { + surface: event_surface, + state, + source, + session: Some(session), + .. + }) if event_surface == surface.id + && state.as_ref() == "blocked" + && source.as_ref() == "hook" + && session.as_ref() == "hook-session" + )); + assert!(!events.try_iter().any(|event| matches!(event, MuxEvent::TreeChanged))); } #[test] - fn kitty_quota_updates_delay_terminal_creation_until_startup_is_safe() { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let gate = Arc::new((Mutex::new(false), Condvar::new())); - *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ - let gate = gate.clone(); - move |surface, limits, _deadline| { - if surface.id == first.id { - let (released, changed) = &*gate; - let mut released = released.lock().unwrap(); - while !*released { - released = changed.wait(released).unwrap(); - } - } - surface.set_kitty_graphics_limits( - limits.image_bytes, - limits.inflight_bytes, - limits.images, - limits.placements, - ) - } - })); - let (sender, receiver) = std::sync::mpsc::channel(); - let creating_mux = mux.clone(); - let creator = std::thread::spawn(move || { - let result = creating_mux.new_tab(Some(pane), None, Some((80, 24))); - let _ = sender.send(result); - }); + fn raw_and_resource_agent_reports_share_durable_order_across_restart() { + let root = std::env::temp_dir() + .join(format!("cmux-agent-coordinator-{}", WorkspacePublicId::random().unwrap())); + let session = "agent-coordinator"; + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let created = public_request( + &mux, + "agent-create", + "workspace.create", + serde_json::json!({ + "machine":"current", + "session":"current", + "initial_content":"terminal", + }), + Some("agent-create"), + ); + let terminal_id = + TerminalPublicId::parse(created["result"]["value"]["terminal_id"].as_str().unwrap()) + .unwrap(); + let surface = mux.resource_surface_for_terminal(&terminal_id).unwrap(); + let created_revision = + created["result"]["revision"].as_str().unwrap().parse::().unwrap(); + let initial_epoch = mux.resource_event_epoch(); - let created_without_waiting = receiver.recv_timeout(Duration::from_millis(250)).ok(); - let returned_before_release = created_without_waiting.is_some(); - { - let (released, changed) = &*gate; - *released.lock().unwrap() = true; - changed.notify_all(); - } - let surface = match created_without_waiting { - Some(result) => result.unwrap(), - None => receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(), - }; - creator.join().unwrap(); - *mux.kitty_image_budget_operation.lock().unwrap() = None; - wait_for_kitty_image_budget(&mux); + let raw = mux + .report_agent( + surface, + AgentState::Working, + AgentSource::Socket, + Some("raw-session".into()), + ) + .unwrap(); + assert_eq!(raw.state, AgentState::Working); + assert_eq!(mux.with_state(|state| state.resource_revision), created_revision + 1); - assert!( - !returned_before_release, - "terminal creation bypassed the in-flight Kitty quota shrink" + let hook_params = serde_json::json!({ + "machine":"current", + "session":"current", + "terminal_id":terminal_id, + "state":"blocked", + "source":"hook", + "source_session":"hook-session", + "expected_revision":(created_revision + 1).to_string(), + }); + let hook = public_request( + &mux, + "agent-hook", + "agent.report", + hook_params.clone(), + Some("agent-hook"), ); - close_terminal_runtime_for_test(&mux, &surface); - } - - #[test] - fn kitty_quota_restoration_uses_linear_bucket_updates() { - let mux = test_mux(); - let applications = Arc::new(AtomicUsize::new(0)); - *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ - let applications = applications.clone(); - move |surface, limits, _deadline| { - applications.fetch_add(1, Ordering::AcqRel); - surface.set_kitty_graphics_limits( - limits.image_bytes, - limits.inflight_bytes, - limits.images, - limits.placements, - ) - } - })); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut surfaces = vec![first]; - for _ in 1..16 { - surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); - } + assert_eq!(hook["result"]["revision"], (created_revision + 2).to_string()); + assert_eq!(hook["result"]["value"]["source"], "hook"); + assert_eq!(hook["result"]["value"]["state"], "blocked"); - wait_for_kitty_image_budget(&mux); + let ignored = mux + .report_agent( + surface, + AgentState::Done, + AgentSource::Socket, + Some("late-raw-session".into()), + ) + .unwrap(); + assert_eq!(ignored.state, AgentState::Blocked); + assert_eq!(ignored.source, AgentSource::Hook); + assert_eq!(ignored.session.as_deref(), Some("hook-session")); + assert_eq!(mux.with_state(|state| state.resource_revision), created_revision + 3); + assert_eq!(mux.resource_event_epoch(), initial_epoch + 3); + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); - let applied = applications.load(Ordering::Acquire); - assert!( - applied <= surfaces.len() * 4, - "restoring {} terminals applied {applied} Kitty quota updates", - surfaces.len() + let batches = mux.resource_events_after(created_revision).unwrap().batches; + assert_eq!( + batches.iter().map(|batch| batch.revision).collect::>(), + vec![created_revision + 1, created_revision + 2, created_revision + 3] + ); + assert_eq!(batches[0].changes[0]["value"]["source"], "socket"); + assert_eq!(batches[0].changes[0]["value"]["source_session"], "raw-session"); + assert_eq!(batches[1].changes[0]["value"], hook["result"]["value"]); + assert_eq!(batches[2].changes[0]["value"], hook["result"]["value"]); + assert_eq!( + crate::resource_api::public_session_snapshot(&mux).unwrap()["agents"], + serde_json::json!([hook["result"]["value"].clone()]) ); - } - - #[test] - fn kitty_quota_expansion_delays_concurrent_terminal_until_reshrink() { - let mux = test_mux(); - let survivor = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(survivor.id).unwrap()); - let mut surfaces = vec![survivor.clone()]; - for _ in 1..8 { - surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); - } - wait_for_kitty_image_budget(&mux); - let expansion = kitty_image_limits_for_capacity(1); - let gate = Arc::new((Mutex::new(false), Condvar::new())); - let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); - *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ - let gate = gate.clone(); - let survivor_id = survivor.id; - move |surface, limits, _deadline| { - if surface.id == survivor_id && limits == expansion { - let _ = started_sender.try_send(()); - let (released, changed) = &*gate; - let mut released = released.lock().unwrap(); - while !*released { - released = changed.wait(released).unwrap(); - } - } - surface.set_kitty_graphics_limits( - limits.image_bytes, - limits.inflight_bytes, - limits.images, - limits.placements, - ) - } - })); - for surface in surfaces.iter().skip(1) { - close_terminal_runtime_for_test(&mux, surface); - } - started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + let _ = mux.shutdown(); + drop(mux); - let (sender, receiver) = std::sync::mpsc::channel(); - let creating_mux = mux.clone(); - let creator = std::thread::spawn(move || { - let _ = sender.send(creating_mux.new_tab(Some(pane), None, Some((80, 24)))); - }); - let returned_before_release = receiver.recv_timeout(Duration::from_millis(250)).ok(); + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let reopened = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + assert_eq!(reopened.resource_agent_projection_count_for_test().unwrap(), 1); assert!( - returned_before_release.is_none(), - "terminal creation bypassed a stale expansion and consumed output without safe quota" + reopened.list_agents(None, None).is_empty(), + "the legacy live-surface cache must not retain a detached terminal" ); - { - let (released, changed) = &*gate; - *released.lock().unwrap() = true; - changed.notify_all(); - } - let concurrent = receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(); - creator.join().unwrap(); - wait_for_kitty_image_budget(&mux); - *mux.kitty_image_budget_operation.lock().unwrap() = None; - let settled = kitty_image_limits_for_capacity(2).image_bytes; - for surface in [&survivor, &concurrent] { - assert_eq!( - surface - .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) - .unwrap(), - settled - ); - } + assert_eq!( + crate::resource_api::public_session_snapshot(&reopened).unwrap()["agents"], + serde_json::json!([hook["result"]["value"].clone()]) + ); + + let epoch_before_replay = reopened.resource_event_epoch(); + let event_count_before_replay = + reopened.resource_events_after(created_revision).unwrap().batches.len(); + let replay = public_request( + &reopened, + "agent-hook-replay", + "agent.report", + hook_params, + Some("agent-hook"), + ); + assert_eq!(replay["result"]["replayed"], true); + assert_eq!(replay["result"]["revision"], (created_revision + 2).to_string()); + assert_eq!(replay["result"]["value"], hook["result"]["value"]); + assert_eq!(reopened.resource_event_epoch(), epoch_before_replay); + assert_eq!( + reopened.resource_events_after(created_revision).unwrap().batches.len(), + event_count_before_replay + ); + assert_eq!(reopened.resource_agent_projection_count_for_test().unwrap(), 1); + + let _ = reopened.shutdown(); + drop(reopened); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn terminal_creation_never_exposes_a_disabled_kitty_quota() { + fn concurrent_raw_socket_and_resource_hook_reports_serialize_to_hook_state() { let mux = test_mux(); - let survivor = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(survivor.id).unwrap()); - let mut surfaces = vec![survivor.clone()]; - for _ in 1..8 { - surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); - } - wait_for_kitty_image_budget(&mux); - - let expansion = kitty_image_limits_for_capacity(1); - let gate = Arc::new((Mutex::new(false), Condvar::new())); - let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); - *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ - let gate = gate.clone(); - let survivor_id = survivor.id; - move |surface, limits, _deadline| { - if surface.id == survivor_id && limits == expansion { - let _ = started_sender.try_send(()); - let (released, changed) = &*gate; - let mut released = released.lock().unwrap(); - while !*released { - released = changed.wait(released).unwrap(); - } - } - surface.set_kitty_graphics_limits( - limits.image_bytes, - limits.inflight_bytes, - limits.images, - limits.placements, - ) - } - })); - for surface in surfaces.iter().skip(1) { - close_terminal_runtime_for_test(&mux, surface); - } - started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); - - let (sender, receiver) = std::sync::mpsc::channel(); - let creating_mux = mux.clone(); - let creator = std::thread::spawn(move || { - let _ = sender.send(creating_mux.new_tab(Some(pane), None, Some((80, 24)))); + let surface = mux.new_workspace(None, None).unwrap(); + let surface_id = surface.id; + let terminal_id = mux.with_state(|state| { + match state.resource_indexes.content_ids.get(&surface_id).unwrap() { + ContentPublicId::Terminal(terminal_id) => terminal_id.clone(), + ContentPublicId::Browser(_) => panic!("workspace opened a browser"), + } }); - let created_before_rebalance = receiver.recv_timeout(Duration::from_millis(250)).ok(); - let startup_limit = created_before_rebalance.as_ref().map(|result| { - result - .as_ref() - .unwrap() - .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) + let revision = mux.with_state(|state| state.resource_revision); + let barrier = Arc::new(std::sync::Barrier::new(3)); + + let raw_thread = { + let mux = mux.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + mux.report_agent( + surface_id, + AgentState::Working, + AgentSource::Socket, + Some("racing-socket".into()), + ) .unwrap() - }); - { - let (released, changed) = &*gate; - *released.lock().unwrap() = true; - changed.notify_all(); - } - let concurrent = match created_before_rebalance { - Some(result) => result.unwrap(), - None => receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(), + }) }; - creator.join().unwrap(); - *mux.kitty_image_budget_operation.lock().unwrap() = None; - wait_for_kitty_image_budget(&mux); - - let initial_limit = startup_limit.unwrap_or_else(|| { - concurrent - .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) + let hook_thread = { + let mux = mux.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + mux.resource_report_agent_selected( + crate::ResourceSelectors { + machine: Some("current".into()), + session: Some("current".into()), + ..Default::default() + }, + &terminal_id, + AgentState::Blocked, + AgentSource::Hook, + Some("racing-hook".into()), + None, + &WorkspaceMutation::new("racing-hook", "resource-test").unwrap(), + ) .unwrap() - }); + }) + }; + barrier.wait(); + let raw_result = raw_thread.join().unwrap(); + let hook_commit = hook_thread.join().unwrap(); + assert!(matches!(raw_result.source, AgentSource::Socket | AgentSource::Hook)); assert!( - initial_limit > 0, - "a newly launched terminal could consume startup output while Kitty graphics were disabled" + matches!(hook_commit.revision, value if value == revision + 1 || value == revision + 2) ); + + let records = mux.list_agents(Some(surface_id), None); + assert_eq!(records.len(), 1); + assert_eq!(records[0].state, AgentState::Blocked); + assert_eq!(records[0].source, AgentSource::Hook); + assert_eq!(records[0].session.as_deref(), Some("racing-hook")); + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); + let batches = mux.resource_events_after(revision).unwrap().batches; + assert_eq!(batches.len(), 2); + assert_eq!(batches[0].revision, revision + 1); + assert_eq!(batches[1].revision, revision + 2); + assert_eq!(batches[1].changes[0]["value"]["source"], "hook"); + assert_eq!(batches[1].changes[0]["value"]["state"], "blocked"); } #[test] - fn kitty_quota_exhaustion_disables_only_overflow_surfaces_and_promotes_them() { + fn failed_raw_agent_report_rolls_back_projection_memory_revision_and_event() { let mux = test_mux(); - let opts = mux.surface_options.lock().unwrap().clone(); - let owner_limit = - usize::try_from(KITTY_IMAGE_PROCESS_BUDGET_COUNT / KITTY_OBJECT_OWNERS_PER_SURFACE) - .unwrap(); - assert_eq!(owner_limit, 2_048); - let mut surfaces = Vec::with_capacity(owner_limit + 1); - for _ in 0..=owner_limit { - surfaces.push( - Surface::spawn_for_test(mux.next_id(), opts.clone(), Arc::downgrade(&mux)).unwrap(), - ); - } - wait_for_kitty_image_budget(&mux); - - let participating_limit = surfaces[0] - .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) - .unwrap(); - assert!(participating_limit > 0); - assert_eq!( - surfaces[owner_limit] - .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) - .unwrap(), - 0, - "quota exhaustion disabled terminals that already owned a graphics share" - ); + let surface = mux.new_workspace(None, None).unwrap(); + let revision = mux.with_state(|state| state.resource_revision); + let epoch = mux.resource_event_epoch(); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); - mux.unregister_kitty_image_surface(&surfaces[0]).unwrap(); - wait_for_kitty_image_budget(&mux); - assert_eq!( - surfaces[owner_limit] - .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) - .unwrap(), - participating_limit, - "an overflow terminal was not promoted when a graphics share became available" - ); + let error = mux + .report_agent( + surface.id, + AgentState::Working, + AgentSource::Socket, + Some("failed-session".into()), + ) + .unwrap_err(); + assert!(error.to_string().contains("forced resource patch failure")); + assert!(mux.list_agents(None, None).is_empty()); + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 0); + assert_eq!(mux.with_state(|state| state.resource_revision), revision); + assert_eq!(mux.resource_event_epoch(), epoch); + assert!(mux.resource_events_after(revision).unwrap().batches.is_empty()); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(false).unwrap(); } #[test] - fn kitty_quota_worker_retries_a_transient_update_failure() { + fn agent_reports_require_a_terminal_and_never_create_a_default_projection() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); - wait_for_kitty_image_budget(&mux); - - let attempts = Arc::new(AtomicUsize::new(0)); - *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ - let attempts = attempts.clone(); - move |surface, limits, _deadline| { - if attempts.fetch_add(1, Ordering::AcqRel) == 0 { - anyhow::bail!("injected transient Kitty quota failure"); - } - surface.set_kitty_graphics_limits( - limits.image_bytes, - limits.inflight_bytes, - limits.images, - limits.placements, - ) - } - })); - - close_terminal_runtime_for_test(&mux, &second); - let deadline = Instant::now() + Duration::from_secs(1); - while attempts.load(Ordering::Acquire) < 2 && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - - assert!( - attempts.load(Ordering::Acquire) >= 2, - "Kitty quota worker stopped after a transient failure" - ); - wait_for_kitty_image_budget(&mux); + let browser = mux.new_browser_tab("about:blank".into(), None, None).unwrap(); + let revision = mux.with_state(|state| state.resource_revision); + let epoch = mux.resource_event_epoch(); + let error = mux + .report_agent( + browser.id, + AgentState::Working, + AgentSource::Socket, + Some("browser-session".into()), + ) + .unwrap_err(); + assert!(error.to_string().contains("is not a terminal")); + assert!(mux.list_agents(None, None).is_empty()); + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 0); + assert_eq!(mux.with_state(|state| state.resource_revision), revision); + assert_eq!(mux.resource_event_epoch(), epoch); } #[test] - fn kitty_quota_worker_disables_graphics_but_admits_terminals_after_persistent_failure() { + fn closing_a_view_preserves_terminal_side_tables_until_explicit_close() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let first = mux.new_workspace(None, None).unwrap(); let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); - wait_for_kitty_image_budget(&mux); - - let attempts = Arc::new(AtomicUsize::new(0)); - *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ - let attempts = attempts.clone(); - move |_surface, _limits, _deadline| { - attempts.fetch_add(1, Ordering::AcqRel); - anyhow::bail!("injected persistent Kitty quota failure") - } - })); + // A second tab keeps the pane live after the terminal view detaches. + let second = mux.new_tab(Some(pane), None, None).unwrap(); + let host = + mux.resource_terminal_host_identity(&first).expect("test terminal has a host identity"); - close_terminal_runtime_for_test(&mux, &second); - let deadline = Instant::now() + Duration::from_secs(2); - while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } + mux.report_agent( + first.id, + AgentState::Working, + AgentSource::Socket, + Some("conf".to_string()), + ) + .unwrap(); + mux.post_notification( + "Build".to_string(), + "ok".to_string(), + NotificationLevel::Warning, + Some(first.id), + ) + .unwrap(); + assert_eq!(mux.list_agents(Some(first.id), None).len(), 1); + assert!(mux.surface_notification(first.id).is_some()); - assert!( - !mux.kitty_image_budget.lock().unwrap().worker_running, - "Kitty quota worker retried a permanent failure forever" - ); - assert!( - attempts.load(Ordering::Acquire) <= 4, - "Kitty quota worker exceeded its retry budget" - ); + mux.close_surface(first.id).unwrap(); - let started = Instant::now(); - let replacement = mux - .new_tab(Some(pane), None, Some((80, 24))) - .expect("an optional Kitty quota failure blocked terminal creation"); - assert!( - started.elapsed() < Duration::from_millis(250), - "a blocked Kitty quota transition waited for the control timeout" - ); - assert_eq!( - replacement - .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) - .unwrap(), - 0, - "a terminal admitted during a blocked quota transition retained graphics quota" - ); - let budget = mux.kitty_image_budget.lock().unwrap(); + assert_eq!(mux.list_agents(Some(first.id), None).len(), 1); + assert_eq!(mux.list_agents(None, None).len(), 1); + assert!(mux.surface_notification(first.id).is_some()); + assert!(mux.surface(first.id).is_some()); + assert!(mux.with_state(|state| state.surfaces.contains_key(&second.id))); + + let detached_report = mux + .report_agent( + first.id, + AgentState::Blocked, + AgentSource::Hook, + Some("detached-hook".to_string()), + ) + .expect("the terminal host identity remains valid without a view"); + assert_eq!(detached_report.state, AgentState::Blocked); + assert_eq!(detached_report.session.as_deref(), Some("detached-hook")); + + mux.close_terminal(&host.terminal_id, &host.incarnation).unwrap(); + assert!(mux.list_agents(None, None).is_empty()); + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 0); assert!( - !budget.entries.get(&replacement.id).unwrap().owns_quota, - "a terminal admitted during a blocked quota transition became a quota owner" + crate::resource_api::public_session_snapshot(&mux).unwrap()["agents"] + .as_array() + .unwrap() + .is_empty() ); + assert!(mux.surface_notification(first.id).is_none()); + assert!(mux.surface(first.id).is_none()); } #[test] - fn kitty_quota_reconnect_clears_its_block_and_rebalances() { + fn replayed_host_close_does_not_acquire_public_resource_effects() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); - wait_for_kitty_image_budget(&mux); - - let first_id = first.id; - *mux.kitty_image_budget_operation.lock().unwrap() = - Some(Arc::new(move |surface, limits, _deadline| { - if surface.id == first_id { - anyhow::bail!("injected disconnected Kitty quota surface"); - } - surface.set_kitty_graphics_limits( - limits.image_bytes, - limits.inflight_bytes, - limits.images, - limits.placements, - ) - })); + let surface = mux.new_workspace(None, None).unwrap(); + let host = mux + .resource_terminal_host_identity(&surface) + .expect("test terminal has a host identity"); + let public_id = match &surface.resource_identity().unwrap().content_id { + ContentPublicId::Terminal(public_id) => public_id.clone(), + ContentPublicId::Browser(_) => panic!("workspace opened a browser"), + }; + let mutation = WorkspaceMutation::new("lost-host-close-reply", "legacy-client").unwrap(); + let resource_revision = mux.with_state(|state| state.resource_revision); - close_terminal_runtime_for_test(&mux, &second); - let deadline = Instant::now() + Duration::from_secs(2); - while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - assert!(mux.kitty_image_budget.lock().unwrap().blocked_surfaces.contains(&first.id)); + let host_close = mux + .workspace_registry + .lock() + .unwrap() + .close_terminal(&mutation, None, None, &host.terminal_id, Some(&host.incarnation)) + .unwrap(); + assert!(!host_close.replayed); + assert_eq!( + mux.workspace_registry.lock().unwrap().terminal_resource_id(&host.terminal_id).unwrap(), + Some(public_id.clone()) + ); - *mux.kitty_image_budget_operation.lock().unwrap() = None; - let authoritative = - first.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(); - assert!(mux.reconcile_reconnected_kitty_image_surface(&first, authoritative)); - wait_for_kitty_image_budget(&mux); + let retry = mux + .close_terminal_with_mutation( + &host.terminal_id, + Some(&host.incarnation), + None, + None, + &mutation, + ) + .unwrap(); - let replacement = mux - .new_tab(Some(pane), None, Some((80, 24))) - .expect("a reconciled reconnect must admit later terminals"); - wait_for_kitty_image_budget(&mux); - close_terminal_runtime_for_test(&mux, &replacement); + assert_eq!(retry.terminal_revision, host_close.revision); + assert_eq!(mux.with_state(|state| state.resource_revision), resource_revision); + assert!(mux.surface(surface.id).is_some()); + assert_eq!( + mux.workspace_registry.lock().unwrap().terminal_resource_id(&host.terminal_id).unwrap(), + Some(public_id) + ); } #[test] - fn kitty_quota_worker_stops_waiting_for_an_operation_that_ignores_its_deadline() { + fn replayed_resource_close_does_not_acquire_terminal_effects() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); - wait_for_kitty_image_budget(&mux); - - let gate = Arc::new((Mutex::new(false), Condvar::new())); - let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); - let (finished_sender, finished_receiver) = std::sync::mpsc::sync_channel(1); - *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ - let gate = gate.clone(); - move |_surface, _limits, _deadline| { - let _ = started_sender.try_send(()); - let (released, changed) = &*gate; - let mut released = released.lock().unwrap(); - while !*released { - released = changed.wait(released).unwrap(); - } - let _ = finished_sender.try_send(()); - anyhow::bail!("released persistent Kitty quota operation") - } - })); + let surface = mux.new_workspace(None, None).unwrap(); + let host = mux + .resource_terminal_host_identity(&surface) + .expect("test terminal has a host identity"); + let public_id = match &surface.resource_identity().unwrap().content_id { + ContentPublicId::Terminal(public_id) => public_id.clone(), + ContentPublicId::Browser(_) => panic!("workspace opened a browser"), + }; + let host_close = mux + .workspace_registry + .lock() + .unwrap() + .close_terminal( + &WorkspaceMutation::new("closed-host", "legacy-client").unwrap(), + None, + None, + &host.terminal_id, + Some(&host.incarnation), + ) + .unwrap(); + let mutation = + WorkspaceMutation::new("lost-resource-close-reply", "resource-client").unwrap(); + let fingerprint = serde_json::json!({ + "op":"close-terminal", + "terminal_id":host.terminal_id, + "incarnation":host.incarnation, + }); + let resource_revision = mux.with_state(|state| state.resource_revision); + let resource_close = mux + .workspace_registry + .lock() + .unwrap() + .commit_resource_patch( + &mutation, + "terminal.close", + &fingerprint, + None, + Some(resource_revision), + &ResourcePatch { changes: Vec::new() }, + &serde_json::json!({}), + &serde_json::json!([]), + ) + .unwrap(); - close_terminal_runtime_for_test(&mux, &second); - started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); - let deadline = Instant::now() + Duration::from_secs(4); - while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - let stopped = !mux.kitty_image_budget.lock().unwrap().worker_running; - { - let (released, changed) = &*gate; - *released.lock().unwrap() = true; - changed.notify_all(); - } - finished_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + let retry = mux + .close_terminal_with_mutation( + &host.terminal_id, + Some(&host.incarnation), + None, + None, + &mutation, + ) + .unwrap(); - assert!(stopped, "Kitty quota worker waited forever for an operation past its deadline"); + assert!(retry.already_closed); + assert_eq!(retry.terminal_revision, host_close.revision); + assert_eq!(mux.with_state(|state| state.resource_revision), resource_close.revision); + assert!(mux.surface(surface.id).is_some()); + assert_eq!( + mux.workspace_registry.lock().unwrap().terminal_resource_id(&host.terminal_id).unwrap(), + Some(public_id) + ); } #[test] - fn kitty_quota_worker_exhausts_deadline_pool_admission_rejections() { + fn failed_browser_surface_attach_kills_worker() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); - wait_for_kitty_image_budget(&mux); - - { - let mut state = mux - .deadline_fanout_pool - .inner - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(state.admitted_jobs, 0); - state.admitted_jobs = CELL_PIXEL_FANOUT_MAX_WORKERS; - } - - close_terminal_runtime_for_test(&mux, &second); - let deadline = Instant::now() + Duration::from_secs(2); - while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - let (stopped, survivor_blocked, closed_retired) = { - let budget = mux.kitty_image_budget.lock().unwrap(); - ( - !budget.worker_running, - budget.blocked_surfaces.contains(&first.id), - !budget.entries.contains_key(&second.id), - ) - }; + let opts = mux.surface_options.lock().unwrap().clone(); + let surface = browser::new_surface( + 999, + "https://example.test".to_string(), + (10, 5), + (8, 16), + &opts, + Arc::downgrade(&mux), + ) + .unwrap(); + let browser = surface.as_browser().expect("browser surface"); + let done = browser.take_worker_done_for_test(); + let mut owner_reservation = mux.reserve_surface_owner().unwrap(); - { - let mut state = mux - .deadline_fanout_pool - .inner - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.admitted_jobs = 0; - mux.deadline_fanout_pool.inner.changed.notify_all(); - } - let cleanup_deadline = Instant::now() + Duration::from_secs(1); - while mux.kitty_image_budget.lock().unwrap().worker_running - && Instant::now() < cleanup_deadline - { - std::thread::sleep(Duration::from_millis(5)); + assert!(matches!( + mux.attach_browser_surface_to_pane_or_kill( + 123_456, + &surface, + 1, + &mut owner_reservation, + ), + BrowserSurfaceAttach::MissingPane + )); + assert!(browser.is_dead()); + done.recv_timeout(Duration::from_secs(1)) + .expect("browser worker exited after failed attach"); + } + + #[test] + fn failed_browser_tab_attach_retains_its_shutdown_owner() { + fn read_ws_json(ws: &mut tungstenite::WebSocket) -> Value { + loop { + match ws.read().unwrap() { + tungstenite::Message::Text(text) => { + return serde_json::from_str(&text).unwrap(); + } + tungstenite::Message::Binary(bytes) => { + return serde_json::from_slice(&bytes).unwrap(); + } + _ => {} + } + } } - assert!(stopped, "Kitty quota worker retried pool admission forever"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + ws.send(tungstenite::Message::Text( + serde_json::json!({"id": discover["id"], "result": {}}).to_string().into(), + )) + .unwrap(); + }); + let runtime = BrowserRuntime::connect_external_for_test(&format!( + "ws://{address}/devtools/browser/fake" + )) + .unwrap(); + server.join().unwrap(); assert!( - survivor_blocked, - "pool admission exhaustion did not fail closed for the live surface" + runtime.wait_until_closed(Instant::now() + Duration::from_secs(1)), + "test CDP runtime did not observe disconnect" ); - assert!(closed_retired, "a closed surface remained in the Kitty quota registry"); - } - #[test] - fn cell_pixel_fanout_runs_concurrently_with_one_shared_deadline() { - let pool = DeadlineFanoutPool::new(); - let (warm_tx, warm_rx) = std::sync::mpsc::sync_channel(1); - assert!(pool.submit(Box::new(move || warm_tx.send(()).unwrap()))); - warm_rx.recv_timeout(Duration::from_secs(1)).expect("warmup fanout job did not run"); - let items = (0..8).collect::>(); - let active = Arc::new(AtomicUsize::new(0)); - let max_active = Arc::new(AtomicUsize::new(0)); - let deadline = Instant::now() + Duration::from_secs(1); - let operation_active = active; - let operation_max_active = max_active.clone(); + let mux = test_mux(); + let initial = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(initial.id).unwrap()); + *mux.browser_tab_after_spawn.lock().unwrap() = Some(Arc::new({ + let mux = Arc::downgrade(&mux); + let runtime = runtime.clone(); + move |surface| { + let mux = mux.upgrade().expect("test mux remained alive through browser spawn"); + surface.as_browser().unwrap().install_shutdown_session_for_test( + runtime.clone(), + "target-race", + "session-race", + ); + mux.state.lock().unwrap().panes.remove(&pane); + } + })); - let results = - bounded_deadline_map(&pool, &items, deadline, move |item, observed_deadline| { - assert_eq!(observed_deadline, deadline); - let concurrent = operation_active.fetch_add(1, Ordering::AcqRel) + 1; - operation_max_active.fetch_max(concurrent, Ordering::AcqRel); - std::thread::sleep(Duration::from_millis(10)); - operation_active.fetch_sub(1, Ordering::AcqRel); - item * 2 - }); + let result = mux.new_browser_tab("about:blank".into(), Some(pane), Some((80, 24))); + mux.request_daemon_shutdown(); + assert!( + mux.async_surface_creations.wait_until_idle(Instant::now() + Duration::from_secs(1)), + "async browser creation did not finish" + ); + assert!(result.is_err(), "browser attached after its pane disappeared"); assert_eq!( - results - .into_iter() - .map(|result| match result { - DeadlineMapResult::Complete(result) => Some(result), - DeadlineMapResult::Pending(_) | DeadlineMapResult::Unscheduled => None, - }) - .collect::>>() - .unwrap(), - vec![0, 2, 4, 6, 8, 10, 12, 14] + mux.shutdown_owners.len(), + 1, + "failed browser attachment discarded its retryable target owner" ); - assert!(max_active.load(Ordering::Acquire) > 1); + initial.kill(); + runtime.shutdown(); } #[test] - fn deadline_fanout_rejects_work_after_the_shared_deadline() { - let pool = DeadlineFanoutPool::new(); - let calls = Arc::new(AtomicUsize::new(0)); - let operation_calls = calls.clone(); - let deadline = Instant::now().checked_sub(Duration::from_millis(1)).unwrap(); + fn notification_sets_unread_and_clears_when_tab_is_viewed() { + let mux = test_mux(); + let first = mux.new_workspace(None, None).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, None).unwrap(); + let notification = mux + .post_notification( + "Build".to_string(), + "ok".to_string(), + NotificationLevel::Warning, + Some(first.id), + ) + .unwrap(); - let results = bounded_deadline_map(&pool, &[1_u8], deadline, move |item, _| { - operation_calls.fetch_add(1, Ordering::AcqRel); - *item - }); + let state = mux.surface_notification(first.id).unwrap(); + assert_eq!(state.notification, notification); + assert_eq!(state.level, NotificationLevel::Warning); + assert!(state.unread); - assert!(matches!(results.as_slice(), [DeadlineMapResult::Unscheduled])); - assert_eq!(calls.load(Ordering::Acquire), 0); + mux.select_tab(Some(pane), Some(1), None); + assert!(mux.surface_notification(first.id).is_some()); + mux.select_tab(Some(pane), Some(0), None); + assert!(mux.surface_notification(first.id).is_none()); + assert!(mux.surface_notification(second.id).is_none()); } #[test] - fn fanout_completion_after_the_shared_deadline_remains_retryable() { - let deadline = Instant::now(); - let pending = DeadlinePending { - result: Arc::new(Mutex::new(Some(DeadlineCompletion { - completed_at: deadline + Duration::from_millis(1), - value: 42, - }))), - }; + fn browser_notification_is_owned_by_its_placement_and_clears_when_viewed() { + let mux = test_mux(); + let terminal = mux.new_workspace(None, None).unwrap(); + let pane = mux.with_state(|state| state.pane_of(terminal.id).unwrap()); + let browser = + mux.new_browser_tab("about:blank#notification".into(), Some(pane), None).unwrap(); + mux.select_tab(Some(pane), Some(0), None); - assert_eq!(pending.try_take_before(deadline), None); - assert_eq!(pending.try_take(), Some(42)); + let notification = mux + .post_notification( + "Browser".into(), + "ready".into(), + NotificationLevel::Info, + Some(browser.id), + ) + .unwrap(); + let unread = mux.surface_notification(browser.id).unwrap(); + assert_eq!(unread.notification, notification); + assert!(unread.unread); + assert!(mux.surface_notification(terminal.id).is_none()); + + mux.select_tab(Some(pane), Some(1), None); + assert!(mux.surface_notification(browser.id).is_none()); } #[test] - fn deadline_fanout_workers_are_lazy_and_reclaim_idle_threads() { - let pool = DeadlineFanoutPool::new(); - assert_eq!(pool.worker_count(), 0, "fanout construction eagerly retained workers"); + fn notification_to_shared_default_surface_waits_for_explicit_view_acknowledgement() { + let mux = test_mux(); + let events = mux.subscribe(); + let surface = mux.new_workspace(None, None).unwrap(); + assert_eq!(mux.active_surface(), Some(surface.id)); - let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1); - assert!(pool.submit(Box::new(move || { - finished_tx.send(()).unwrap(); - }))); - finished_rx.recv_timeout(Duration::from_secs(1)).expect("fanout job did not run"); + let notification = mux + .post_notification( + "Build".to_string(), + "ok".to_string(), + NotificationLevel::Info, + Some(surface.id), + ) + .unwrap(); - let deadline = Instant::now() + Duration::from_secs(1); - while pool.worker_count() != 0 && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - assert_eq!(pool.worker_count(), 0, "idle fanout worker was retained"); + let unread = mux.surface_notification(surface.id).unwrap(); + assert_eq!(unread.notification, notification); + assert!(unread.unread); + assert!(events.try_iter().any(|event| { + matches!( + event, + MuxEvent::Notification(note) + if note.notification == notification && note.surface == Some(surface.id) + ) + })); - let (teardown_tx, teardown_rx) = std::sync::mpsc::sync_channel(1); - assert!(pool.submit(Box::new(move || teardown_tx.send(()).unwrap()))); - teardown_rx.recv_timeout(Duration::from_secs(1)).expect("teardown fanout job did not run"); - let inner = Arc::downgrade(&pool.inner); - drop(pool); - let deadline = Instant::now() + Duration::from_secs(1); - while inner.upgrade().is_some() && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - assert!(inner.upgrade().is_none(), "dropped fanout pool retained its executor state"); + mux.select_tab(None, Some(0), None); + assert!(mux.surface_notification(surface.id).is_none()); } #[test] - fn cell_pixel_fanout_returns_when_an_operation_ignores_its_deadline() { - let gate = Arc::new((Mutex::new(false), Condvar::new())); - let (sender, receiver) = std::sync::mpsc::sync_channel(1); - let caller_gate = gate.clone(); - let caller = std::thread::spawn(move || { - let pool = DeadlineFanoutPool::new(); - let items = vec![1_u8]; - let deadline = Instant::now() + Duration::from_millis(30); - let results = bounded_deadline_map(&pool, &items, deadline, move |item, _| { - let (released, changed) = &*caller_gate; - let mut released = released.lock().unwrap(); - while !*released { - released = changed.wait(released).unwrap(); - } - *item - }); - let _ = sender.send(results); + fn notification_ledger_is_bounded_newest_first_and_uses_public_ids() { + let mux = test_mux(); + let terminal = mux.new_workspace(None, None).unwrap(); + let terminal_id = terminal.terminal_public_id().cloned().unwrap(); + mux.post_notification( + "attached".into(), + "terminal".into(), + NotificationLevel::Info, + Some(terminal.id), + ) + .unwrap(); + assert_eq!(mux.resource_notifications(1)[0].terminal_id, Some(terminal_id)); + + for index in 0..300 { + mux.post_notification( + format!("notice-{index}"), + String::new(), + NotificationLevel::Info, + None, + ) + .unwrap(); + } + let notifications = mux.resource_notifications(1_000); + assert_eq!(notifications.len(), 256); + assert_eq!(notifications.first().unwrap().title, "notice-299"); + assert_eq!(notifications.last().unwrap().title, "notice-44"); + assert_eq!( + notifications.iter().map(|notification| ¬ification.id).collect::>().len(), + 256 + ); + assert!( + notifications.windows(2).all(|pair| pair[0].created_at_ms >= pair[1].created_at_ms) + ); + } + + fn seed_split_ratio_tree(mux: &Arc) -> (PaneId, PaneId, PaneId, SplitId, SplitId) { + let first = mux.new_workspace(None, None).unwrap(); + let p1 = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.split(p1, SplitDir::Right, None).unwrap(); + let p2 = mux.with_state(|state| state.pane_of(second.id).unwrap()); + let third = mux.split(p1, SplitDir::Right, None).unwrap(); + let p3 = mux.with_state(|state| state.pane_of(third.id).unwrap()); + let (root_split, inner_split) = mux.with_state(|state| { + let Node::Split { id: root, a, .. } = &state.workspaces[0].screens[0].root else { + panic!("root should be split"); + }; + let Node::Split { id: inner, .. } = a.as_ref() else { + panic!("first child should be split"); + }; + (*root, *inner) }); + (p1, p2, p3, root_split, inner_split) + } - let returned_before_release = receiver.recv_timeout(Duration::from_millis(150)).is_ok(); - { - let (released, changed) = &*gate; - *released.lock().unwrap() = true; - changed.notify_all(); + fn leaf_spec() -> LayoutSpec { + LayoutSpec::Leaf(LayoutLeafSpec { cwd: None, command: None }) + } + + fn split_spec(dir: SplitDir, ratio: f32, a: LayoutSpec, b: LayoutSpec) -> LayoutSpec { + LayoutSpec::Split { dir, ratio, a: Box::new(a), b: Box::new(b) } + } + + fn node_shape(node: &Node) -> String { + match node { + Node::Leaf(_) => "leaf".to_string(), + Node::Split { dir, ratio, a, b, .. } => { + let dir = match dir { + SplitDir::Right => "right", + SplitDir::Down => "down", + }; + format!("{dir}:{ratio:.2}({}, {})", node_shape(a), node_shape(b)) + } + Node::Stack { panes, expanded } => format!("stack:{panes:?}:{expanded}"), } - caller.join().unwrap(); - - assert!( - returned_before_release, - "fanout joined an operation after its shared deadline elapsed" - ); } - #[test] - fn timed_out_cell_pixel_failure_is_retried_after_the_worker_finishes() { - let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - let attempts = Arc::new(AtomicUsize::new(0)); - *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(20)); - *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ - let attempts = attempts.clone(); - move |surface, target, _deadline| { - if attempts.fetch_add(1, Ordering::AcqRel) == 0 { - std::thread::sleep(Duration::from_millis(60)); - anyhow::bail!("injected late cell-pixel failure"); - } - surface.set_cell_pixel_size(target.0, target.1).map(|changed| changed.then_some(0)) + fn spec_shape(spec: &LayoutSpec) -> String { + match spec { + LayoutSpec::Leaf(_) => "leaf".to_string(), + LayoutSpec::Split { dir, ratio, a, b } => { + let dir = match dir { + SplitDir::Right => "right", + SplitDir::Down => "down", + }; + format!( + "{dir}:{:.2}({}, {})", + clamp_split_ratio(*ratio), + spec_shape(a), + spec_shape(b) + ) + } + LayoutSpec::Stack { pane_count, expanded_index } => { + format!("stack:{pane_count}:{expanded_index}") } - })); - - let update = mux.set_cell_pixel_size(9, 18); - assert_eq!(update.failures.len(), 1); - assert!(update.failures[0].deferred); - - let deadline = Instant::now() + Duration::from_secs(1); - while mux.cell_pixel_size() != (9, 18) && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); } - assert!( - attempts.load(Ordering::Acquire) >= 2, - "the failed operation that finished after the shared deadline was never retried" - ); - assert_eq!(mux.cell_pixel_size(), (9, 18)); - assert_eq!(surface.test_cell_pixel_size(), (9, 18)); } - #[test] - fn cell_pixel_fanout_retries_the_same_metric_before_publishing_it() { - let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - surface.fail_next_test_master_resize(); - - let update = mux.set_cell_pixel_size(9, 18); - - assert!(update.resizes.is_empty()); - assert_eq!(update.failures.len(), 1); - assert_eq!(update.failures[0].surface, surface.id); - assert!(update.failures[0].error.contains("injected PTY master resize failure")); - assert_eq!( - mux.cell_pixel_size(), - (8, 16), - "published metric must remain at the last fully converged value" - ); - assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); - assert_eq!(surface.test_cell_pixel_size(), (8, 16)); - let created_while_pending = mux.new_workspace(None, Some((80, 24))).unwrap(); - assert_eq!(created_while_pending.test_cell_pixel_size(), (8, 16)); - let master = surface.test_master_size(); - assert_eq!( - (master.cols, master.rows, master.pixel_width, master.pixel_height), - (80, 24, 640, 384) - ); + fn leaf_order(node: &Node) -> Vec { + let mut ids = Vec::new(); + node.pane_ids(&mut ids); + ids + } - let retried = mux.set_cell_pixel_size(9, 18); - assert!(retried.failures.is_empty()); - assert_eq!( - retried.resizes, - vec![(surface.id, (80, 24), 0), (created_while_pending.id, (80, 24), 0)] - ); - assert_eq!(mux.cell_pixel_size(), (9, 18)); - assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + fn screen_root(mux: &Mux, screen: ScreenId) -> Node { + mux.with_state(|s| { + s.workspaces + .iter() + .flat_map(|ws| ws.screens.iter()) + .find(|candidate| candidate.id == screen) + .unwrap() + .root + .clone() + }) } #[test] - fn cell_pixel_fanout_retries_work_skipped_after_the_shared_deadline() { + fn apply_layout_round_trip_reproduces_tree_shape_and_ratios() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - for _ in 1..=CELL_PIXEL_FANOUT_MAX_WORKERS { - mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); - } - let mut surface_ids = - mux.state.lock().unwrap().surfaces.keys().copied().collect::>(); - surface_ids.sort_unstable(); - assert_eq!(surface_ids.len(), CELL_PIXEL_FANOUT_MAX_WORKERS + 1); - let last_surface = *surface_ids.last().unwrap(); - let last_attempts = Arc::new(AtomicUsize::new(0)); - *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(50)); - *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ - let last_attempts = last_attempts.clone(); - move |surface, target, deadline| { - if surface.id != last_surface { - std::thread::sleep( - deadline.saturating_duration_since(Instant::now()) - + Duration::from_millis(10), - ); - } else { - last_attempts.fetch_add(1, Ordering::AcqRel); - if Instant::now() >= deadline { - return Err( - crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed.into() - ); + let spec = split_spec( + SplitDir::Right, + 0.33, + leaf_spec(), + split_spec(SplitDir::Down, 0.67, leaf_spec(), leaf_spec()), + ); + let first = mux.apply_layout(None, Some("round-trip".into()), &spec, None).unwrap(); + let exported_shape = node_shape(&screen_root(&mux, first.screen)); + mux.with_state(|state| assert_eq!(state.workspaces[0].name, "0")); + + let round_trip_spec = mux.with_state(|s| { + fn from_node(node: &Node) -> LayoutSpec { + match node { + Node::Leaf(_) => leaf_spec(), + Node::Split { dir, ratio, a, b, .. } => { + split_spec(*dir, *ratio, from_node(a), from_node(b)) } + Node::Stack { panes, expanded } => LayoutSpec::Stack { + pane_count: panes.len(), + expanded_index: panes + .iter() + .position(|pane| pane == expanded) + .expect("valid stack expansion"), + }, } - surface.set_cell_pixel_size(target.0, target.1).map(|changed| changed.then_some(0)) } - })); - - let update = mux.set_cell_pixel_size(9, 18); + from_node(&s.workspaces[0].screens[0].root) + }); + let second = + mux.apply_layout(None, Some("round-trip-2".into()), &round_trip_spec, None).unwrap(); + let applied_shape = node_shape(&screen_root(&mux, second.screen)); - // The first worker wave finishes just after the shared deadline. Its - // completion callbacks may reconcile those surfaces before this call - // returns, depending on scheduler timing. The unscheduled final - // surface must remain deferred in either ordering. - assert!(update.failures.iter().any(|failure| failure.surface == last_surface)); - assert!(update.failures.iter().all(|failure| failure.deferred)); - let retry_deadline = Instant::now() + Duration::from_secs(1); - while mux.cell_pixel_size() != (9, 18) && Instant::now() < retry_deadline { - std::thread::sleep(Duration::from_millis(10)); - } - assert_eq!( - mux.cell_pixel_size(), - (9, 18), - "work that missed the first worker wave was never reconciled" - ); - assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); - assert_eq!(last_attempts.load(Ordering::Acquire), 1); + assert_eq!(exported_shape, spec_shape(&spec)); + assert_eq!(applied_shape, exported_shape); + assert_eq!(first.panes.len(), 3); + assert_eq!(second.panes.len(), 3); } #[test] - fn cell_pixel_deadline_retries_stop_and_report_terminal_failure() { + fn apply_layout_holds_target_workspace_lifecycle_through_commit() { let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - let attempts = Arc::new(AtomicUsize::new(0)); - *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(10)); - *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ - let attempts = attempts.clone(); - move |_, _, _| { - attempts.fetch_add(1, Ordering::AcqRel); - Err(crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed.into()) + let target = mux.create_empty_workspace(Some("target".into()), None, None).unwrap(); + let (reserved_tx, reserved_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let release_rx = Arc::new(Mutex::new(release_rx)); + *mux.layout_apply_after_workspace_reservation.lock().unwrap() = Some(Arc::new({ + move || { + reserved_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); } })); - let events = mux.subscribe(); - - let update = mux.set_cell_pixel_size(9, 18); + let apply = std::thread::spawn({ + let mux = mux.clone(); + move || mux.apply_layout(Some(target.workspace), None, &leaf_spec(), None) + }); + reserved_rx.recv().unwrap(); - assert_eq!(update.failures.len(), 1); - assert!(update.failures[0].deferred); - let deadline = Instant::now() + Duration::from_secs(1); - while mux.cell_pixel_retries.lock().unwrap().worker_running && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(10)); - } - assert!( - !mux.cell_pixel_retries.lock().unwrap().worker_running, - "deadline retries did not stop after {} attempts", - attempts.load(Ordering::Acquire) - ); - assert_eq!( - attempts.load(Ordering::Acquire), - usize::from(CELL_PIXEL_RETRY_MAX_ATTEMPTS) + 1 - ); - assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); - assert!(events.try_iter().any(|event| matches!( - event, - MuxEvent::GraphicsStatus(GraphicsStatus::CellPixelUpdateRetriesExhausted { - attempts: CELL_PIXEL_RETRY_MAX_ATTEMPTS, - remaining: 1, - cell_pixels: (9, 18), - }) - ))); + let (close_done_tx, close_done_rx) = std::sync::mpsc::sync_channel(1); + let close = std::thread::spawn({ + let mux = mux.clone(); + move || { + close_done_tx + .send(mux.close_workspace_at_revision(target.workspace, Some(1))) + .unwrap(); + } + }); + let premature_close = close_done_rx.recv_timeout(Duration::from_millis(250)); + let closed_early = premature_close.is_ok(); + release_tx.send(()).unwrap(); + let applied = apply.join(); + let close_result = match premature_close { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => close_done_rx.recv().unwrap(), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + panic!("workspace close result channel disconnected") + } + }; + close.join().unwrap(); + *mux.layout_apply_after_workspace_reservation.lock().unwrap() = None; - assert!(surface.set_cell_pixel_size(9, 18).unwrap()); - mux.reconcile_deferred_cell_pixel_ack(surface.id, (9, 18)); - assert_eq!(mux.cell_pixel_size(), (9, 18)); - assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + assert!(!closed_early, "workspace closed before layout commit"); + assert!(applied.unwrap().is_ok()); + assert_eq!(close_result.unwrap(), Some(2)); + mux.shutdown().unwrap(); } #[test] - fn late_cell_pixel_ack_publishes_the_pending_creation_metric() { + fn apply_layout_constructs_stack_with_requested_expansion() { let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - surface.fail_next_test_master_resize(); - - let update = mux.set_cell_pixel_size(9, 18); - assert_eq!(update.failures.len(), 1); - assert_eq!(mux.cell_pixel_size(), (8, 16)); - assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); - - assert!(surface.set_cell_pixel_size(9, 18).unwrap()); - mux.reconcile_deferred_cell_pixel_ack(surface.id, (9, 18)); + let applied = mux + .apply_layout( + None, + Some("stack".into()), + &LayoutSpec::Stack { pane_count: 3, expanded_index: 1 }, + None, + ) + .unwrap(); + let root = screen_root(&mux, applied.screen); - assert_eq!(mux.cell_pixel_size(), (9, 18)); - assert_eq!(mux.cell_pixel_creation_size(), (9, 18)); - assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + assert!(matches!( + root, + Node::Stack { ref panes, expanded } + if panes.len() == 3 && expanded == applied.panes[1].pane + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].active_pane, applied.panes[1].pane); + }); } #[test] - fn deferred_cell_pixel_target_sizes_new_surfaces_before_the_ack_arrives() { + fn pane_neighbor_returns_directional_adjacency() { let mux = test_mux(); - let pending_surface = 99_999; - *mux.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { - generation: 1, - target: (9, 18), - failures: HashSet::from([pending_surface]), - use_for_creation: true, - }); - - let created = mux.new_workspace(None, Some((80, 24))).unwrap(); + let applied = mux + .apply_layout( + None, + None, + &split_spec( + SplitDir::Right, + 0.5, + leaf_spec(), + split_spec(SplitDir::Down, 0.5, leaf_spec(), leaf_spec()), + ), + None, + ) + .unwrap(); + let p1 = applied.panes[0].pane; + let p2 = applied.panes[1].pane; + let p3 = applied.panes[2].pane; - assert_eq!(mux.cell_pixel_size(), (8, 16)); - assert_eq!(created.test_cell_pixel_size(), (9, 18)); - mux.reconcile_deferred_cell_pixel_ack(pending_surface, (9, 18)); - assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(mux.pane_neighbor(p1, Direction::Right).unwrap(), Some(p2)); + assert_eq!(mux.pane_neighbor(p2, Direction::Down).unwrap(), Some(p3)); + assert_eq!(mux.pane_neighbor(p1, Direction::Left).unwrap(), None); } #[test] - fn closing_the_last_failed_terminal_publishes_pending_cell_pixels() { + fn focus_direction_moves_active_pane() { let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - *mux.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { - generation: 1, - target: (9, 18), - failures: HashSet::from([surface.id]), - use_for_creation: false, - }); - - assert_eq!(mux.cell_pixel_size(), (8, 16)); - close_terminal_runtime_for_test(&mux, &surface); + let first = mux.new_workspace(None, None).unwrap(); + let p1 = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.split(p1, SplitDir::Right, None).unwrap(); + let p2 = mux.with_state(|state| state.pane_of(second.id).unwrap()); + assert!(mux.focus_pane(p1)); - assert_eq!(mux.cell_pixel_size(), (9, 18)); - assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + assert_eq!(mux.focus_direction(None, Direction::Right).unwrap(), p2); + mux.with_state(|s| assert_eq!(s.workspaces[0].screens[0].active_pane, p2)); + assert!(mux.focus_direction(None, Direction::Right).is_err()); } #[test] - fn failed_viewer_resize_preserves_previous_report_and_creation_default() { + fn focus_direction_returns_to_most_recently_focused_adjacent_pane() { let mux = test_mux(); - let missing_surface = 99_999; - mux.record_client_size(90, 30); - mux.client_sizing - .lock() - .unwrap() - .surfaces - .entry(missing_surface) - .or_default() - .insert(7, (80, 25)); + let first = mux.new_workspace(None, None).unwrap(); + let left = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.split(left, SplitDir::Right, None).unwrap(); + let top_right = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let bottom = mux.split(top_right, SplitDir::Down, None).unwrap(); + let bottom_right = mux.with_state(|state| state.pane_of(bottom.id).unwrap()); - assert!(mux.resize_surface_for_client(missing_surface, 7, 120, 40).is_err()); - assert_eq!(mux.client_surface_size(missing_surface, 7), Some((80, 25))); - assert_eq!(mux.new_workspace(None, None).unwrap().size(), (90, 30)); + assert!(mux.focus_pane(top_right)); + assert!(mux.focus_pane(bottom_right)); + assert_eq!(mux.focus_direction(None, Direction::Left).unwrap(), left); + assert_eq!(mux.focus_direction(None, Direction::Right).unwrap(), bottom_right); } #[test] - fn failed_first_terminal_report_removes_empty_sizing_indexes() { + fn fresh_layout_focus_uses_layout_order_for_unfocused_candidates() { let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - assert_eq!(mux.claim_terminal_geometry(surface.id, 0), Some(true)); - surface.fail_next_test_master_resize(); + let applied = mux + .apply_layout( + None, + None, + &split_spec( + SplitDir::Right, + 0.5, + leaf_spec(), + split_spec(SplitDir::Down, 0.5, leaf_spec(), leaf_spec()), + ), + None, + ) + .unwrap(); - assert!(mux.resize_surface_for_client(surface.id, 0, 100, 30).is_err()); - let sizing = mux.client_sizing.lock().unwrap(); - assert!(!sizing.surfaces.contains_key(&surface.id)); - assert!(!sizing.report_order.contains_key(&(surface.id, 0))); - assert!(!sizing.terminal_runtime_by_placement.contains_key(&surface.id)); + assert_eq!(mux.focus_direction(None, Direction::Right).unwrap(), applied.panes[1].pane); } #[test] - fn concurrent_browser_viewer_reports_settle_at_the_shared_minimum() { + fn selecting_a_tab_in_an_inactive_pane_does_not_change_focus_recency() { let mux = test_mux(); - let surface = mux - .new_browser_tab("about:blank#concurrent-sizing".into(), None, Some((100, 30))) - .unwrap(); - let surface_id = surface.id; - let pause_first = Arc::new(AtomicBool::new(true)); - let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(1); - let release = Arc::new((Mutex::new(false), Condvar::new())); - let hook_release = release.clone(); - mux.set_client_resize_before_apply(Some(Arc::new(move || { - if pause_first.swap(false, Ordering::SeqCst) { - reached_tx.send(()).unwrap(); - let (lock, ready) = &*hook_release; - let mut released = lock.lock().unwrap(); - while !*released { - released = ready.wait(released).unwrap(); - } - } - }))); - - let first_mux = mux.clone(); - let first = std::thread::spawn(move || { - first_mux.resize_surface_for_client(surface_id, 1, 120, 40).unwrap(); - }); - reached_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - - let second_mux = mux.clone(); - let second = std::thread::spawn(move || { - second_mux.resize_surface_for_client(surface_id, 2, 80, 50).unwrap(); - }); - let (lock, ready) = &*release; - *lock.lock().unwrap() = true; - ready.notify_all(); - first.join().unwrap(); - second.join().unwrap(); + let first = mux.new_workspace(None, None).unwrap(); + let left = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right_surface = mux.split(left, SplitDir::Right, None).unwrap(); + let right = mux.with_state(|state| state.pane_of(right_surface.id).unwrap()); + assert!(mux.focus_pane(left)); + mux.new_tab(Some(right), None, None).unwrap(); + let before = mux.with_state(|state| state.panes[&right].focused_at); - if let Some(pending) = surface.pending_resize_completion(80, 40).unwrap() { - assert_eq!(pending.completion.recv_timeout(Duration::from_secs(10)).unwrap(), Ok(())); - } - assert_eq!(surface.size(), (80, 40)); - mux.set_client_resize_before_apply(None); - mux.shutdown(); - } + mux.select_tab(Some(right), Some(0), None); - fn projected_terminal_view(mux: &Arc, source: &Arc) -> Arc { - let terminal_id = source - .terminal_public_id() - .cloned() - .expect("test terminal has a public content identity"); - let pane = mux.with_state(|state| state.pane_of(source.id).unwrap()); - mux.resource_project_terminal_selected( - crate::ResourceSelectors { - terminal: Some(terminal_id.to_string()), - ..Mux::ordinary_resource_selectors() - }, - mux.ordinary_pane_selectors(pane).unwrap(), - usize::MAX, - None, - None, - &WorkspaceMutation::local("test-terminal-projection"), - ) - .unwrap(); - mux.with_state(|state| { - state - .placements_of_content(&ContentPublicId::Terminal(terminal_id)) - .iter() - .copied() - .find(|placement| *placement != source.id) - .and_then(|placement| state.surfaces.get(&placement)) - .cloned() - .expect("projected terminal view is materialized") - }) + assert_eq!(mux.with_state(|state| state.panes[&right].focused_at), before); } #[test] - fn projected_views_share_one_graphics_and_cell_pixel_owner() { + fn moving_a_tab_to_another_pane_stamps_the_new_focus() { let mux = test_mux(); - let source = mux.new_workspace(None, Some((80, 24))).unwrap(); - wait_for_kitty_image_budget(&mux); - assert_eq!(mux.kitty_image_budget.lock().unwrap().entries.len(), 1); - - let projected = projected_terminal_view(&mux, &source); - wait_for_kitty_image_budget(&mux); - assert!(source.shares_terminal_runtime(&projected)); - assert_eq!(mux.kitty_image_budget.lock().unwrap().entries.len(), 1); - - let applications = Arc::new(AtomicUsize::new(0)); - *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ - let applications = applications.clone(); - move |surface, target, _deadline| { - applications.fetch_add(1, Ordering::AcqRel); - surface.set_cell_pixel_size(target.0, target.1).map(|changed| changed.then_some(0)) - } - })); - let update = mux.set_cell_pixel_size(9, 18); - assert!(update.failures.is_empty()); - assert_eq!(applications.load(Ordering::Acquire), 1); - assert_eq!(source.test_cell_pixel_size(), (9, 18)); - assert_eq!(projected.test_cell_pixel_size(), (9, 18)); + let first = mux.new_workspace(None, None).unwrap(); + let left = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let extra = mux.new_tab(Some(left), None, None).unwrap(); + let right_surface = mux.split(left, SplitDir::Right, None).unwrap(); + let right = mux.with_state(|state| state.pane_of(right_surface.id).unwrap()); + assert!(mux.focus_pane(left)); + let before = mux.with_state(|state| state.panes[&right].focused_at); - assert!(mux.close_surface(source.id).unwrap()); - wait_for_kitty_image_budget(&mux); - assert_eq!(mux.kitty_image_budget.lock().unwrap().entries.len(), 1); + assert!(mux.move_tab(extra.id, right, 0)); - close_terminal_runtime_for_test(&mux, &source); - wait_for_kitty_image_budget(&mux); - assert!(mux.kitty_image_budget.lock().unwrap().entries.is_empty()); + mux.with_state(|state| { + assert_eq!(state.active_pane(), Some(right)); + assert!(state.panes[&right].focused_at > before); + }); } #[test] - fn passive_terminal_viewports_never_resize_the_canonical_grid() { + fn swap_pane_exchanges_leaf_positions_and_preserves_surfaces() { let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - - assert!(!mux.resize_surface_for_client(surface.id, 0, 120, 40).unwrap()); - assert_eq!(surface.size(), (80, 24)); - assert_eq!(mux.client_surface_size(surface.id, 0), Some((120, 40))); - assert!(!mux.client_size_participates(surface.id, 0)); - - assert_eq!(mux.claim_terminal_geometry(surface.id, 0), Some(true)); - assert_eq!(surface.size(), (120, 40)); - assert!(mux.client_size_participates(surface.id, 0)); + let applied = mux + .apply_layout( + None, + None, + &split_spec(SplitDir::Right, 0.5, leaf_spec(), leaf_spec()), + None, + ) + .unwrap(); + let p1 = applied.panes[0].pane; + let s1 = applied.panes[0].surface; + let p2 = applied.panes[1].pane; + let s2 = applied.panes[1].surface; + assert_eq!(leaf_order(&screen_root(&mux, applied.screen)), vec![p1, p2]); - assert!(!mux.resize_surface_for_client(surface.id, 7, 60, 20).unwrap()); - assert_eq!(surface.size(), (120, 40)); - assert_eq!(mux.client_surface_size(surface.id, 7), Some((60, 20))); - assert!(!mux.client_size_participates(surface.id, 7)); + assert!(mux.swap_panes(p1, p2)); + assert_eq!(leaf_order(&screen_root(&mux, applied.screen)), vec![p2, p1]); + mux.with_state(|s| { + assert_eq!(s.panes[&p1].tabs, vec![s1]); + assert_eq!(s.panes[&p2].tabs, vec![s2]); + }); } #[test] - fn geometry_authority_moves_between_views_of_one_terminal() { + fn zoom_pane_toggles_screen_zoom_state() { let mux = test_mux(); - let source = mux.new_workspace(None, Some((80, 24))).unwrap(); - let projected = projected_terminal_view(&mux, &source); - - assert!(source.shares_terminal_runtime(&projected)); - assert!(!mux.resize_surface_for_client(source.id, 0, 110, 35).unwrap()); - assert!(!mux.resize_surface_for_client(projected.id, 0, 60, 20).unwrap()); + let applied = mux + .apply_layout( + None, + None, + &split_spec(SplitDir::Right, 0.5, leaf_spec(), leaf_spec()), + None, + ) + .unwrap(); + let p2 = applied.panes[1].pane; - assert_eq!(mux.claim_terminal_geometry(source.id, 0), Some(true)); - assert_eq!(source.size(), (110, 35)); - assert_eq!(projected.size(), (110, 35)); + let zoomed = mux.zoom_pane(Some(p2), ZoomMode::Toggle).unwrap(); + assert_eq!(zoomed.zoomed_pane, Some(p2)); + mux.with_state(|s| assert_eq!(s.workspaces[0].screens[0].zoomed_pane, Some(p2))); - assert_eq!(mux.claim_terminal_geometry(projected.id, 0), Some(true)); - assert_eq!(source.size(), (60, 20)); - assert_eq!(projected.size(), (60, 20)); - assert!(!mux.client_size_participates(source.id, 0)); - assert!(mux.client_size_participates(projected.id, 0)); + let restored = mux.zoom_pane(Some(p2), ZoomMode::Toggle).unwrap(); + assert_eq!(restored.zoomed_pane, None); + mux.with_state(|s| assert_eq!(s.workspaces[0].screens[0].zoomed_pane, None)); } #[test] - fn terminal_runtime_events_fan_out_only_to_materialized_views() { + fn process_info_metadata_is_recorded_for_spawned_surface() { let mux = test_mux(); - let source = mux.new_workspace(None, Some((80, 24))).unwrap(); - let projected = projected_terminal_view(&mux, &source); - let events = mux.subscribe(); - let expected = HashSet::from([source.id, projected.id]); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + let applied = mux + .apply_layout( + None, + None, + &LayoutSpec::Leaf(LayoutLeafSpec { + cwd: Some(cwd.clone()), + command: Some(vec!["echo".into(), "ok".into()]), + }), + None, + ) + .unwrap(); + let surface = mux.surface(applied.panes[0].surface).unwrap(); - let collect_two = |map: fn(MuxEvent) -> Option| { - (0..2) - .map(|_| events.recv_timeout(Duration::from_secs(1)).unwrap()) - .filter_map(map) - .collect::>() - }; + assert_eq!(surface.process_id(), Some(surface.id as u32)); + assert_eq!(surface.spawn_command().as_deref(), Some("echo ok")); + assert_eq!(surface.spawn_cwd().as_deref(), Some(cwd.as_str())); + } - mux.emit_terminal_output(source.id); - assert_eq!( - collect_two(|event| match event { - MuxEvent::SurfaceOutput(surface) => Some(surface), - _ => None, - }), - expected - ); - mux.emit_terminal_title(source.id, Arc::from("shared title")); - assert_eq!( - collect_two(|event| match event { - MuxEvent::TitleChanged { surface, .. } => Some(surface), - _ => None, - }), - expected - ); - mux.emit_terminal_bell(source.id); - assert_eq!( - collect_two(|event| match event { - MuxEvent::Bell(surface) => Some(surface), - _ => None, - }), - expected - ); - mux.emit_terminal_resized(source.id, 100, 30, Some(7)); - assert_eq!( - collect_two(|event| match event { - MuxEvent::SurfaceResized { surface, .. } => Some(surface), - _ => None, - }), - expected - ); - mux.emit_terminal_scroll(source.id, 4, false); - assert_eq!( - collect_two(|event| match event { - MuxEvent::ScrollChanged { surface, .. } => Some(surface), - _ => None, - }), - expected - ); - mux.emit_terminal_exited(source.id); - assert_eq!( - collect_two(|event| match event { - MuxEvent::SurfaceExited(surface) => Some(surface), - _ => None, - }), - expected - ); + #[test] + fn split_and_close_collapses_tree() { + let mux = test_mux(); + let s1 = mux.new_workspace(None, None).unwrap(); + let p1 = mux.with_state(|s| s.pane_of(s1.id).unwrap()); + let s2 = mux.split(p1, SplitDir::Right, None).unwrap(); + let p2 = mux.with_state(|s| s.pane_of(s2.id).unwrap()); + let s3 = mux.split(p2, SplitDir::Down, None).unwrap(); + let p3 = mux.with_state(|s| s.pane_of(s3.id).unwrap()); - mux.set_default_colors(DefaultColors { - fg: Some(crate::Rgb { r: 0x11, g: 0x22, b: 0x33 }), - ..Default::default() + mux.with_state(|s| { + let mut ids = Vec::new(); + s.workspaces[0].screens[0].root.pane_ids(&mut ids); + assert_eq!(ids, vec![p1, p2, p3]); }); - assert_eq!( - collect_two(|event| match event { - MuxEvent::SurfaceOutput(surface) => Some(surface), - _ => None, - }), - expected - ); - assert!(matches!(events.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))); - mux.remove_surface_runtime_for_test(source.id).unwrap(); - mux.remove_surface_runtime_for_test(projected.id).unwrap(); - mux.emit_terminal_output(source.id); - assert!(matches!(events.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))); + mux.close_pane(p2).unwrap(); + mux.with_state(|s| { + let mut ids = Vec::new(); + s.workspaces[0].screens[0].root.pane_ids(&mut ids); + assert_eq!(ids, vec![p1, p3]); + }); + + mux.close_pane(p1).unwrap(); + mux.close_pane(p3).unwrap(); + assert_eq!(mux.surface_count(), 0); + mux.with_state(|s| { + assert_eq!(s.workspaces.len(), 1); + assert!(s.workspaces[0].screens.is_empty()); + assert_eq!(s.workspace_revision, 1); + }); } - #[cfg(unix)] #[test] - fn hosted_terminal_exit_atomically_detaches_every_projected_view() { + fn new_pane_right_wraps_the_screen_in_a_viewport_split() { let mux = test_mux(); - let source = mux.new_workspace(None, Some((80, 24))).unwrap(); - let projected = projected_terminal_view(&mux, &source); - let terminal_id = source - .terminal_public_id() - .cloned() - .expect("hosted terminal has a public content identity"); - let host = mux - .resource_terminal_host_identity(&source) - .expect("hosted terminal has a durable process identity"); - let runtime_id = source.terminal_runtime_id().expect("hosted terminal has a runtime"); - let placements = HashSet::from([source.id, projected.id]); - let tab_ids = mux.with_state(|state| { - placements - .iter() - .map(|surface| state.resource_indexes.tab_ids[surface].clone()) - .collect::>() - }); - let before_revision = mux.workspace_registry.lock().unwrap().resource_revision().unwrap(); - let events = mux.subscribe(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); + let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); - mux.surface_exited(source.id); + let appended = + mux.new_pane_right(second_pane, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); + let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); mux.with_state(|state| { - assert!( - state - .placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())) - .is_empty(), - "an exited runtime retained a projected view" - ); - assert!( - !state.terminal_catalog.contains_key(&terminal_id), - "an exited runtime remained catalog-owned" + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, appended_pane); + assert_eq!(screen.zoomed_pane, None); + assert_eq!(screen.viewport_splits.len(), 1); + let Node::Split { id, dir, ratio, a, b } = &screen.root else { + panic!("viewport pane must wrap the screen root"); + }; + assert_eq!(*dir, SplitDir::Right); + assert!((*ratio - 0.6).abs() < f32::EPSILON); + assert!(a.contains(first_pane)); + assert!(a.contains(second_pane)); + assert!(matches!(b.as_ref(), Node::Leaf(pane) if *pane == appended_pane)); + assert_eq!(screen.viewport_splits[id], DEFAULT_VIEWPORT_PANE_WIDTH); + + let layout = layout_screen_with_viewport( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 24 }, + Some(screen.active_pane), + screen.viewport_base_width.unwrap_or(1.0), + &screen.viewport_splits, ); - assert!( - !state.terminal_catalog_by_runtime.contains_key(&runtime_id), - "an exited runtime retained its reverse catalog entry" + assert_eq!(layout.virtual_width, 133); + assert_eq!(layout.rect_of(first_pane).unwrap().width, 40); + assert_eq!(layout.rect_of(second_pane).unwrap().width, 40); + assert_eq!( + layout.rect_of(appended_pane).unwrap(), + VirtualRect { x: 80, y: 0, width: 53, height: 24 } ); }); - let resolved = mux.resolve_terminal(&host.terminal_id).unwrap().unwrap(); - assert_eq!(resolved.surface, None); - assert_eq!(resolved.terminal.lifecycle, TerminalLifecycle::Exited); - let waited = mux.wait_for_terminal_exit(&terminal_id, Some(Duration::ZERO)).unwrap(); - assert_eq!(waited["state"], "exited"); + assert_eq!(mux.pane_neighbor(second_pane, Direction::Right).unwrap(), Some(appended_pane)); + assert_eq!(mux.pane_neighbor(appended_pane, Direction::Left).unwrap(), Some(second_pane)); + } - let batches = mux.resource_events_after(before_revision).unwrap().batches; - assert_eq!(batches.len(), 1, "exit lifecycle and topology split across revisions"); - let changes = batches[0].changes.as_array().unwrap(); - for tab_id in tab_ids { - assert!( - changes.iter().any(|change| { - change["kind"] == "delete" - && change["resource"] == "tab" - && change["id"] == tab_id.as_str() - }), - "atomic exit omitted projected tab {tab_id}" - ); + #[test] + fn viewport_neighbor_navigation_remains_adjacent_past_u16_layout_extent() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut panes = vec![first_pane]; + + for _ in 0..12 { + let previous = *panes.last().unwrap(); + let surface = + mux.new_pane_right(previous, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); + panes.push(mux.with_state(|state| state.pane_of(surface.id).unwrap())); } - let deadline = Instant::now() + Duration::from_secs(1); - let mut exited = HashSet::new(); - while exited != placements { - assert!(Instant::now() < deadline, "not every projected view observed terminal exit"); - if let Ok(MuxEvent::SurfaceExited(surface)) = - events.recv_timeout(Duration::from_millis(20)) - { - exited.insert(surface); - } + for pair in panes.windows(2) { + let [left, right] = pair else { unreachable!() }; + assert_eq!(mux.pane_neighbor(*left, Direction::Right).unwrap(), Some(*right)); + assert_eq!(mux.pane_neighbor(*right, Direction::Left).unwrap(), Some(*left)); + assert_eq!(mux.pane_focus_neighbor(*left, Direction::Right).unwrap(), Some(*right)); + assert_eq!(mux.pane_focus_neighbor(*right, Direction::Left).unwrap(), Some(*left)); } - mux.shutdown(); } #[test] - fn receipted_creation_atomically_uses_client_local_fallback_and_replays_it() { + fn new_pane_right_rejects_invalid_width_before_spawning() { let mux = test_mux(); - let fallback = mux.new_workspace(None, Some((80, 24))).unwrap(); - let fallback_pane = mux.with_state(|state| state.pane_of(fallback.id).unwrap()); - let primary = mux.split(fallback_pane, SplitDir::Right, Some((40, 24))).unwrap(); - let primary_pane = mux.with_state(|state| state.pane_of(primary.id).unwrap()); - let candidates = vec![ - mux.resource_selectors_for_pane(Some(primary_pane)).unwrap(), - mux.resource_selectors_for_pane(Some(fallback_pane)).unwrap(), - ]; - assert!(mux.close_pane(primary_pane).unwrap()); + let first = mux.new_workspace(None, None).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let surfaces = mux.with_state(|state| state.surfaces.len()); - let mutation = WorkspaceMutation::new( - "selector-fallback-receipt-00000001".to_string(), - "selector-fallback-test".to_string(), - ) - .unwrap(); - let fields = Map::from_iter([ - ("direction".to_string(), serde_json::json!("right")), - ("cols".to_string(), serde_json::json!(40)), - ("rows".to_string(), serde_json::json!(24)), - ]); - let (created, replayed) = mux - .receipted_surface_creation( - ResourceOperation::PaneSplit, - candidates.clone(), - fields.clone(), - &mutation, - ) - .unwrap(); - assert!(!replayed); - assert_ne!(created, fallback.id); - assert!(mux.with_state(|state| state.pane_of(created).is_some())); + assert!(mux.new_pane_right(pane, 0.0, None).is_err()); + assert_eq!(mux.with_state(|state| state.surfaces.len()), surfaces); + } - assert!(mux.close_pane(fallback_pane).unwrap()); - let (replayed_surface, replayed) = mux - .receipted_surface_creation(ResourceOperation::PaneSplit, candidates, fields, &mutation) - .unwrap(); - assert!(replayed); - assert_eq!(replayed_surface, created); - mux.close_surface(created).unwrap(); + #[test] + fn new_pane_right_discards_spawn_when_target_disappears_before_attachment() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let weak = Arc::downgrade(&mux); + *mux.viewport_split_after_spawn.lock().unwrap() = Some(Arc::new(move || { + weak.upgrade().unwrap().close_pane_for_resource_effect(first_pane).unwrap(); + })); + + let error = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap_err(); + + assert_eq!(error.to_string(), "pane creation failed"); + mux.with_state(|state| { + assert!(state.surfaces.is_empty()); + assert!(state.panes.is_empty()); + assert!(state.workspaces[0].screens.is_empty()); + }); } #[test] - fn receipted_creation_rejects_fallbacks_that_drop_to_backend_selection() { + fn viewport_columns_resize_independently() { let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - let pane_selectors = mux.resource_selectors_for_pane(Some(pane)).unwrap(); - let session_selectors = crate::ResourceSelectors { - machine: pane_selectors.machine.clone(), - session: pane_selectors.session.clone(), - ..crate::ResourceSelectors::default() - }; - let mutation = WorkspaceMutation::new( - "selector-fallback-receipt-00000002".to_string(), - "selector-fallback-test".to_string(), - ) - .unwrap(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); + let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); + let appended = + mux.new_pane_right(second_pane, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); + let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); - let error = mux - .receipted_surface_creation( - ResourceOperation::TabCreateTerminal, - vec![pane_selectors, session_selectors], - Map::new(), - &mutation, - ) - .unwrap_err(); + assert!(mux.set_viewport_pane_width(first_pane, 0.75)); + assert!(mux.set_viewport_pane_width(appended_pane, 0.5)); + assert!(!mux.set_viewport_pane_width(appended_pane, 0.0)); - assert_eq!(error.to_string(), "creation selector fallbacks require pane selectors"); - assert_eq!(mux.with_state(|state| state.surfaces.len()), 1); - mux.close_surface(surface.id).unwrap(); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, Some(0.75)); + let appended_split = match screen + .root + .viewport_column_owner(appended_pane, &screen.viewport_splits) + .unwrap() + { + ViewportColumn::Split(split) => split, + ViewportColumn::Base => panic!("appended pane must own a viewport split"), + }; + assert_eq!(screen.viewport_splits[&appended_split], 0.5); + let Node::Split { ratio, .. } = &screen.root else { + panic!("viewport layout must retain its root split"); + }; + assert!((*ratio - 0.6).abs() < f32::EPSILON); + + let layout = layout_screen_with_viewport( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 24 }, + Some(screen.active_pane), + screen.viewport_base_width.unwrap(), + &screen.viewport_splits, + ); + assert_eq!(layout.virtual_width, 100); + assert_eq!(layout.rect_of(first_pane).unwrap().width, 30); + assert_eq!(layout.rect_of(second_pane).unwrap().width, 30); + assert_eq!( + layout.rect_of(appended_pane).unwrap(), + VirtualRect { x: 60, y: 0, width: 40, height: 24 } + ); + }); } - #[cfg(unix)] #[test] - fn failed_atomic_exit_retries_without_exposing_partial_topology() { - const TERMINAL: &str = "0000000000004000800000000000003f"; - const INCARNATION: &str = "1000000000004000800000000000003f"; + fn projected_viewport_split_ratio_resizes_the_authoritative_column() { let mux = test_mux(); - let workspace = mux - .create_empty_workspace( - Some("failed-atomic-exit".into()), - Some("018f6e21-7b70-7e70-8000-00000000103f".into()), - None, - ) - .unwrap(); - let surface = - mux.seed_running_terminal_for_test(TERMINAL, INCARNATION, &workspace.key).unwrap(); - let public_id = - mux.workspace_registry.lock().unwrap().terminal_resource_id(TERMINAL).unwrap().unwrap(); - mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let appended = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); + let split = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + match &screen.root { + Node::Split { id, .. } => *id, + _ => panic!("viewport layout must expose a compatibility split"), + } + }); - mux.surface_exited(surface); + let events = mux.subscribe(); + assert!(mux.set_split_ratio_checked(split, 0.5).is_ok()); + assert!(matches!(events.recv().unwrap(), MuxEvent::LayoutChanged(_))); + let after_change = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + (screen.layout_revision, screen.layout_undo.len()) + }); + assert!(mux.set_split_ratio_checked(split, 0.5).is_ok()); + assert!(matches!( + mux.set_split_ratio_checked(split, 0.25), + Err(LayoutRatioError::UnrepresentableViewportWidth { split: rejected, .. }) + if rejected == split + )); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!((screen.layout_revision, screen.layout_undo.len()), after_change); + assert_eq!(screen.viewport_splits[&split], 1.0); + assert_eq!( + screen + .layout_columns + .iter() + .find(|column| column.root.contains(appended_pane)) + .map(|column| column.width), + Some(1.0) + ); + assert!(matches!( + &screen.root, + Node::Split { id, ratio, .. } + if *id == split && (*ratio - 0.5).abs() < f32::EPSILON + )); + assert!(state.split_screens.contains_key(&split)); + }); + assert!(events.try_iter().next().is_none()); + } + + fn seed_high_ratio_viewport_projection() -> (Arc, PaneId, SplitId) { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let middle = mux.new_pane_right(first_pane, 1.0, Some((78, 22))).unwrap(); + let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); + let right = + mux.new_pane_right(middle_pane, MIN_VIEWPORT_PANE_WIDTH, Some((6, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let split = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let Node::Split { id, ratio, .. } = &screen.root else { + panic!("three columns should expose a projected root split"); + }; + assert!((*ratio - (2.0 / 2.1)).abs() < f32::EPSILON); + *id + }); + (mux, right_pane, split) + } + fn assert_high_ratio_projection_resize_applied( + mux: &Mux, + expected_width: f32, + undo_count_before: usize, + ) { mux.with_state(|state| { - assert_eq!( - state.placements_of_content(&ContentPublicId::Terminal(public_id.clone())), - &[surface], - "failed transaction exposed a partial detach" + let screen = &state.workspaces[0].screens[0]; + assert!( + (screen.layout_columns[2].width - expected_width).abs() < 1e-6, + "projected ratio should update authoritative width: {:?}", + screen.layout_columns ); - assert!(state.terminal_catalog.contains_key(&public_id)); + assert_eq!(screen.layout_undo.len(), undo_count_before + 1); + assert!(screen.layout_column_projection_is_consistent()); }); - assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() - .lifecycle, - TerminalLifecycle::Running - ); - assert!(mux.terminal_exit_detaches.contains(TERMINAL)); - - mux.workspace_registry.lock().unwrap().set_resource_patch_failure(false).unwrap(); - let deadline = Instant::now() + Duration::from_secs(2); - loop { - let detached = mux.resolve_terminal(TERMINAL).unwrap().unwrap().surface.is_none(); - let retry_finished = !mux.terminal_exit_detaches.contains(TERMINAL); - if detached && retry_finished { - break; - } - assert!(Instant::now() < deadline, "atomic exit retry did not detach the terminal"); - std::thread::sleep(Duration::from_millis(5)); - } - assert_eq!( - mux.resolve_terminal(TERMINAL).unwrap().unwrap().terminal.lifecycle, - TerminalLifecycle::Exited - ); - assert!( - mux.terminal_exit_detaches.wait_until_finished(TERMINAL, deadline), - "terminal detach retry worker did not release its ownership" - ); } #[test] - fn releasing_geometry_authority_freezes_the_terminal() { - let mux = test_mux(); - let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - - mux.resize_surface_for_client(surface.id, 0, 100, 30).unwrap(); - mux.claim_terminal_geometry(surface.id, 0).unwrap(); - assert_eq!(surface.size(), (100, 30)); + fn maximum_projected_split_ratio_still_resizes_authoritative_column() { + let (mux, _right_pane, split) = seed_high_ratio_viewport_projection(); + let expected_width = 2.0 * (1.0 - 0.95) / 0.95; + let undo_count_before = + mux.with_state(|state| state.workspaces[0].screens[0].layout_undo.len()); + let events = mux.subscribe(); - assert_eq!(mux.release_terminal_geometry(surface.id), Some(true)); - assert!(!mux.resize_surface_for_client(surface.id, 0, 70, 20).unwrap()); - assert_eq!(surface.size(), (100, 30)); + assert!(mux.set_split_ratio_checked(split, 0.95).is_ok()); - assert_eq!(mux.claim_terminal_geometry(surface.id, 0), Some(true)); - assert_eq!(surface.size(), (70, 20)); + assert_high_ratio_projection_resize_applied(&mux, expected_width, undo_count_before); + assert!(matches!(events.try_recv(), Ok(MuxEvent::LayoutChanged(_)))); } #[test] - fn removing_the_authoritative_viewport_freezes_the_terminal() { - let mux = test_mux(); - let source = mux.new_workspace(None, Some((80, 24))).unwrap(); - let projected = projected_terminal_view(&mux, &source); + fn maximum_pane_addressed_ratio_still_resizes_authoritative_column() { + let (mux, right_pane, _split) = seed_high_ratio_viewport_projection(); + let expected_width = 2.0 * (1.0 - 0.95) / 0.95; + let undo_count_before = + mux.with_state(|state| state.workspaces[0].screens[0].layout_undo.len()); + let events = mux.subscribe(); - mux.resize_surface_for_client(projected.id, 0, 96, 28).unwrap(); - mux.claim_terminal_geometry(projected.id, 0).unwrap(); - assert_eq!(source.size(), (96, 28)); + assert!(mux.set_ratio_checked(right_pane, SplitDir::Right, 0.95).is_ok()); - mux.remove_surface_size_client(projected.id, 0); - assert!(!mux.resize_surface_for_client(source.id, 7, 55, 18).unwrap()); - assert_eq!(source.size(), (96, 28)); - assert!(!mux.client_size_participates(projected.id, 0)); + assert_high_ratio_projection_resize_applied(&mux, expected_width, undo_count_before); + assert!(matches!(events.try_recv(), Ok(MuxEvent::TreeChanged))); + assert!(matches!(events.try_recv(), Ok(MuxEvent::LayoutChanged(_)))); } #[test] - fn only_authoritative_reports_seed_future_terminal_geometry() { - let mux = test_mux(); - let source = mux.new_workspace(None, Some((80, 24))).unwrap(); - - mux.resize_surface_for_client(source.id, 0, 111, 33).unwrap(); - mux.claim_terminal_geometry(source.id, 0).unwrap(); - assert_eq!(mux.new_workspace(None, None).unwrap().size(), (111, 33)); - - mux.resize_surface_for_client(source.id, 7, 50, 15).unwrap(); - assert_eq!(mux.new_workspace(None, None).unwrap().size(), (111, 33)); - } - #[test] - fn agent_reports_apply_hook_authority() { + fn set_ratio_resizes_a_projected_viewport_split() { let mux = test_mux(); - let surface = mux.new_workspace(None, None).unwrap(); - let events = mux.subscribe(); - let initial_revision = mux.with_state(|state| state.resource_revision); - let initial_epoch = mux.resource_event_epoch(); - let socket = mux - .report_agent( - surface.id, - AgentState::Working, - AgentSource::Socket, - Some("socket-session".to_string()), - ) - .unwrap(); - assert_eq!(socket.state, AgentState::Working); - assert_eq!(socket.source, AgentSource::Socket); - assert!(matches!( - events.recv_timeout(Duration::from_millis(100)), - Ok(MuxEvent::AgentChanged { - surface: event_surface, - state, - source, - session: Some(session), - .. - }) if event_surface == surface.id - && state.as_ref() == "working" - && source.as_ref() == "socket" - && session.as_ref() == "socket-session" - )); - - let hook = mux - .report_agent( - surface.id, - AgentState::Blocked, - AgentSource::Hook, - Some("hook-session".to_string()), - ) - .unwrap(); - assert_eq!(hook.state, AgentState::Blocked); - assert_eq!(hook.source, AgentSource::Hook); - - let ignored_socket = mux - .report_agent( - surface.id, - AgentState::Done, - AgentSource::Socket, - Some("late-socket".to_string()), - ) - .unwrap(); - assert_eq!(ignored_socket.state, AgentState::Blocked); - assert_eq!(ignored_socket.source, AgentSource::Hook); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let filtered = mux.list_agents(Some(surface.id), Some(AgentState::Blocked)); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].session.as_deref(), Some("hook-session")); - assert!(mux.list_agents(Some(surface.id), Some(AgentState::Done)).is_empty()); - assert_eq!(mux.with_state(|state| state.resource_revision), initial_revision + 3); - assert_eq!(mux.resource_event_epoch(), initial_epoch + 3); - assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); - let resource_events = mux.resource_events_after(initial_revision).unwrap(); - assert_eq!(resource_events.batches.len(), 3); - assert_eq!(resource_events.batches[0].changes[0]["value"]["source"], "socket"); - assert_eq!(resource_events.batches[1].changes[0]["value"]["source"], "hook"); - assert_eq!(resource_events.batches[2].changes[0]["value"]["source"], "hook"); - assert_eq!(resource_events.batches[2].changes[0]["value"]["state"], "blocked"); - assert_eq!( - resource_events.batches[2].changes[0]["value"]["source_session"], - "hook-session" - ); - assert!(matches!( - events.recv_timeout(Duration::from_millis(100)), - Ok(MuxEvent::AgentChanged { - surface: event_surface, - state, - source, - session: Some(session), - .. - }) if event_surface == surface.id - && state.as_ref() == "blocked" - && source.as_ref() == "hook" - && session.as_ref() == "hook-session" - )); - assert!(!events.try_iter().any(|event| matches!(event, MuxEvent::TreeChanged))); + assert!(mux.set_ratio_checked(first_pane, SplitDir::Right, 0.5).is_ok()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let Node::Split { id, ratio, .. } = &screen.root else { + panic!("viewport layout must expose a compatibility split"); + }; + assert!((*ratio - 0.5).abs() < f32::EPSILON); + assert_eq!(screen.viewport_splits[id], 1.0); + assert!(screen.layout_column_projection_is_consistent()); + }); } #[test] - fn raw_and_resource_agent_reports_share_durable_order_across_restart() { - let root = std::env::temp_dir() - .join(format!("cmux-agent-coordinator-{}", WorkspacePublicId::random().unwrap())); - let session = "agent-coordinator"; - let registry = WorkspaceRegistry::open(&root, session).unwrap(); - let mux = Mux::from_workspace_registry( - session.into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); - let created = public_request( - &mux, - "agent-create", - "workspace.create", - serde_json::json!({ - "machine":"current", - "session":"current", - "initial_content":"terminal", - }), - Some("agent-create"), - ); - let terminal_id = - TerminalPublicId::parse(created["result"]["value"]["terminal_id"].as_str().unwrap()) - .unwrap(); - let surface = mux.resource_surface_for_terminal(&terminal_id).unwrap(); - let created_revision = - created["result"]["revision"].as_str().unwrap().parse::().unwrap(); - let initial_epoch = mux.resource_event_epoch(); - - let raw = mux - .report_agent( - surface, - AgentState::Working, - AgentSource::Socket, - Some("raw-session".into()), - ) - .unwrap(); - assert_eq!(raw.state, AgentState::Working); - assert_eq!(mux.with_state(|state| state.resource_revision), created_revision + 1); + fn new_pane_right_inserts_after_the_target_viewport_column() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); + let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); - let hook_params = serde_json::json!({ - "machine":"current", - "session":"current", - "terminal_id":terminal_id, - "state":"blocked", - "source":"hook", - "source_session":"hook-session", - "expected_revision":(created_revision + 1).to_string(), + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let layout = layout_screen_with_viewport( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 24 }, + Some(screen.active_pane), + screen.viewport_base_width.unwrap(), + &screen.viewport_splits, + ); + assert_eq!( + layout.panes.iter().map(|(pane, _)| *pane).collect::>(), + vec![first_pane, middle_pane, second_pane] + ); + assert_eq!(layout.rect_of(first_pane).unwrap().x, 0); + assert_eq!(layout.rect_of(middle_pane).unwrap().x, 80); + assert_eq!(layout.rect_of(second_pane).unwrap().x, 120); }); - let hook = public_request( - &mux, - "agent-hook", - "agent.report", - hook_params.clone(), - Some("agent-hook"), - ); - assert_eq!(hook["result"]["revision"], (created_revision + 2).to_string()); - assert_eq!(hook["result"]["value"]["source"], "hook"); - assert_eq!(hook["result"]["value"]["state"], "blocked"); + } - let ignored = mux - .report_agent( - surface, - AgentState::Done, - AgentSource::Socket, - Some("late-raw-session".into()), - ) - .unwrap(); - assert_eq!(ignored.state, AgentState::Blocked); - assert_eq!(ignored.source, AgentSource::Hook); - assert_eq!(ignored.session.as_deref(), Some("hook-session")); - assert_eq!(mux.with_state(|state| state.resource_revision), created_revision + 3); - assert_eq!(mux.resource_event_epoch(), initial_epoch + 3); - assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); + #[test] + fn viewport_self_swap_is_a_noop_without_undo_history_or_events() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let before = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + (screen.layout_revision, screen.layout_undo.len(), format!("{:?}", screen.root)) + }); + let events = mux.subscribe(); - let batches = mux.resource_events_after(created_revision).unwrap().batches; - assert_eq!( - batches.iter().map(|batch| batch.revision).collect::>(), - vec![created_revision + 1, created_revision + 2, created_revision + 3] - ); - assert_eq!(batches[0].changes[0]["value"]["source"], "socket"); - assert_eq!(batches[0].changes[0]["value"]["source_session"], "raw-session"); - assert_eq!(batches[1].changes[0]["value"], hook["result"]["value"]); - assert_eq!(batches[2].changes[0]["value"], hook["result"]["value"]); - assert_eq!( - crate::resource_api::public_session_snapshot(&mux).unwrap()["agents"], - serde_json::json!([hook["result"]["value"].clone()]) - ); + assert!(!mux.swap_panes(right_pane, right_pane)); - mux.shutdown(); - drop(mux); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_revision, before.0); + assert_eq!(screen.layout_undo.len(), before.1); + assert_eq!(format!("{:?}", screen.root), before.2); + }); + assert!(events.try_iter().next().is_none()); + } - let registry = WorkspaceRegistry::open(&root, session).unwrap(); - let reopened = Mux::from_workspace_registry( - session.into(), - SurfaceOptions::default(), - registry, - ProviderWorkspaceState::default(), - true, - ) - .unwrap(); - assert_eq!(reopened.resource_agent_projection_count_for_test().unwrap(), 1); - assert!( - reopened.list_agents(None, None).is_empty(), - "the legacy live-surface cache must not retain a detached terminal" - ); - assert_eq!( - crate::resource_api::public_session_snapshot(&reopened).unwrap()["agents"], - serde_json::json!([hook["result"]["value"].clone()]) - ); + #[test] + fn zellij_new_pane_rebalances_only_the_focused_layout_column() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let epoch_before_replay = reopened.resource_event_epoch(); - let event_count_before_replay = - reopened.resource_events_after(created_revision).unwrap().batches.len(); - let replay = public_request( - &reopened, - "agent-hook-replay", - "agent.report", - hook_params, - Some("agent-hook"), - ); - assert_eq!(replay["result"]["replayed"], true); - assert_eq!(replay["result"]["revision"], (created_revision + 2).to_string()); - assert_eq!(replay["result"]["value"], hook["result"]["value"]); - assert_eq!(reopened.resource_event_epoch(), epoch_before_replay); - assert_eq!( - reopened.resource_events_after(created_revision).unwrap().batches.len(), - event_count_before_replay - ); - assert_eq!(reopened.resource_agent_projection_count_for_test().unwrap(), 1); + let right_added = mux.new_pane(right_pane, Some((38, 10))).unwrap(); + let right_added_pane = mux.with_state(|state| state.pane_of(right_added.id).unwrap()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns.len(), 2); + assert_eq!(screen.layout_columns[0].root.pane_ids_vec(), vec![first_pane]); + assert_eq!( + screen.layout_columns[1].root.pane_ids_vec(), + vec![right_pane, right_added_pane] + ); + assert_eq!( + screen.layout_columns[1].zellij_auto_layout.as_deref(), + Some([right_pane, right_added_pane].as_slice()) + ); + assert_eq!(screen.viewport_splits.len(), 1); + }); - reopened.shutdown(); - drop(reopened); - std::fs::remove_dir_all(root).unwrap(); + let left_added = mux.new_pane(first_pane, Some((38, 10))).unwrap(); + let left_added_pane = mux.with_state(|state| state.pane_of(left_added.id).unwrap()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!( + screen.layout_columns[0].root.pane_ids_vec(), + vec![first_pane, left_added_pane] + ); + assert_eq!( + screen.layout_columns[1].root.pane_ids_vec(), + vec![right_pane, right_added_pane] + ); + assert_eq!(screen.viewport_base_width, Some(1.0)); + assert_eq!(screen.viewport_splits.values().copied().collect::>(), vec![0.5]); + assert!(screen.layout_column_projection_is_consistent()); + }); } #[test] - fn concurrent_raw_socket_and_resource_hook_reports_serialize_to_hook_state() { + fn layout_undo_removes_only_the_pane_created_in_the_focused_column() { let mux = test_mux(); - let surface = mux.new_workspace(None, None).unwrap(); - let surface_id = surface.id; - let terminal_id = mux.with_state(|state| { - match state.resource_indexes.content_ids.get(&surface_id).unwrap() { - ContentPublicId::Terminal(terminal_id) => terminal_id.clone(), - ContentPublicId::Browser(_) => panic!("workspace opened a browser"), - } - }); - let revision = mux.with_state(|state| state.resource_revision); - let barrier = Arc::new(std::sync::Barrier::new(3)); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let added = mux.new_pane(right_pane, Some((38, 10))).unwrap(); + let added_pane = mux.with_state(|state| state.pane_of(added.id).unwrap()); - let raw_thread = { - let mux = mux.clone(); - let barrier = barrier.clone(); - std::thread::spawn(move || { - barrier.wait(); - mux.report_agent( - surface_id, - AgentState::Working, - AgentSource::Socket, - Some("racing-socket".into()), - ) - .unwrap() - }) - }; - let hook_thread = { - let mux = mux.clone(); - let barrier = barrier.clone(); - std::thread::spawn(move || { - barrier.wait(); - mux.resource_report_agent_selected( - crate::ResourceSelectors { - machine: Some("current".into()), - session: Some("current".into()), - ..Default::default() - }, - &terminal_id, - AgentState::Blocked, - AgentSource::Hook, - Some("racing-hook".into()), - None, - &WorkspaceMutation::new("racing-hook", "resource-test").unwrap(), - ) - .unwrap() - }) + let LayoutUndoResult::ConfirmationRequired { revision, closes_panes, .. } = + mux.undo_layout(added_pane, None, false).unwrap() + else { + panic!("new pane undo must require confirmation"); }; - barrier.wait(); - let raw_result = raw_thread.join().unwrap(); - let hook_commit = hook_thread.join().unwrap(); - assert!(matches!(raw_result.source, AgentSource::Socket | AgentSource::Hook)); - assert!( - matches!(hook_commit.revision, value if value == revision + 1 || value == revision + 2) - ); + assert_eq!(closes_panes, vec![added_pane]); + assert!(matches!( + mux.undo_layout(added_pane, Some(revision), true).unwrap(), + LayoutUndoResult::Undone { .. } + )); - let records = mux.list_agents(Some(surface_id), None); - assert_eq!(records.len(), 1); - assert_eq!(records[0].state, AgentState::Blocked); - assert_eq!(records[0].source, AgentSource::Hook); - assert_eq!(records[0].session.as_deref(), Some("racing-hook")); - assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); - let batches = mux.resource_events_after(revision).unwrap().batches; - assert_eq!(batches.len(), 2); - assert_eq!(batches[0].revision, revision + 1); - assert_eq!(batches[1].revision, revision + 2); - assert_eq!(batches[1].changes[0]["value"]["source"], "hook"); - assert_eq!(batches[1].changes[0]["value"]["state"], "blocked"); + assert_terminal_view_detached(&mux, added.id); + assert!(mux.surface(right.id).is_some()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns.len(), 2); + assert_eq!(screen.layout_columns[0].root.pane_ids_vec(), vec![first_pane]); + assert_eq!(screen.layout_columns[1].root.pane_ids_vec(), vec![right_pane]); + assert!(screen.layout_column_projection_is_consistent()); + }); } #[test] - fn failed_raw_agent_report_rolls_back_projection_memory_revision_and_event() { + fn layout_undo_retires_removed_surface_owner_before_reusing_capacity() { let mux = test_mux(); - let surface = mux.new_workspace(None, None).unwrap(); - let revision = mux.with_state(|state| state.resource_revision); - let epoch = mux.resource_event_epoch(); - mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); + mux.set_shutdown_owner_capacity_for_test(2); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let added = mux.new_pane(first_pane, Some((80, 10))).unwrap(); + let added_pane = mux.with_state(|state| state.pane_of(added.id).unwrap()); - let error = mux - .report_agent( - surface.id, - AgentState::Working, - AgentSource::Socket, - Some("failed-session".into()), - ) - .unwrap_err(); - assert!(error.to_string().contains("forced resource patch failure")); - assert!(mux.list_agents(None, None).is_empty()); - assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 0); - assert_eq!(mux.with_state(|state| state.resource_revision), revision); - assert_eq!(mux.resource_event_epoch(), epoch); - assert!(mux.resource_events_after(revision).unwrap().batches.is_empty()); - mux.workspace_registry.lock().unwrap().set_resource_patch_failure(false).unwrap(); + let LayoutUndoResult::ConfirmationRequired { revision, .. } = + mux.undo_layout(added_pane, None, false).unwrap() + else { + panic!("new pane undo must require confirmation"); + }; + assert!(matches!( + mux.undo_layout(added_pane, Some(revision), true).unwrap(), + LayoutUndoResult::Undone { .. } + )); + + assert_terminal_view_detached(&mux, added.id); + assert!( + mux.shutdown_owners.is_empty(), + "layout undo retained the removed surface owner after confirmed shutdown" + ); + let replacement = mux + .new_pane(first_pane, Some((80, 10))) + .expect("confirmed undo cleanup must release capacity for a replacement pane"); + assert!(mux.surface(replacement.id).is_some()); } #[test] - fn agent_reports_require_a_terminal_and_never_create_a_default_projection() { + fn layout_undo_confirmation_preview_is_read_only() { let mux = test_mux(); - let browser = mux.new_browser_tab("about:blank".into(), None, None).unwrap(); - let revision = mux.with_state(|state| state.resource_revision); - let epoch = mux.resource_event_epoch(); - let error = mux - .report_agent( - browser.id, - AgentState::Working, - AgentSource::Socket, - Some("browser-session".into()), + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let before = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + ( + screen.layout_revision, + format!("{:?}", screen.layout_snapshot()), + format!("{:?}", screen.layout_undo), + state.panes[&right_pane].tabs.clone(), ) - .unwrap_err(); - assert!(error.to_string().contains("is not a terminal")); - assert!(mux.list_agents(None, None).is_empty()); - assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 0); - assert_eq!(mux.with_state(|state| state.resource_revision), revision); - assert_eq!(mux.resource_event_epoch(), epoch); + }); + + let first_preview = mux.undo_layout(right_pane, None, false).unwrap(); + let second_preview = mux.undo_layout(right_pane, None, false).unwrap(); + + assert_eq!( + first_preview, + LayoutUndoResult::ConfirmationRequired { + screen: mux.with_state(|state| state.workspaces[0].screens[0].id), + revision: before.0, + closes_panes: vec![right_pane], + } + ); + assert_eq!(second_preview, first_preview); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_revision, before.0); + assert_eq!(format!("{:?}", screen.layout_snapshot()), before.1); + assert_eq!(format!("{:?}", screen.layout_undo), before.2); + assert_eq!(state.panes[&right_pane].tabs, before.3); + }); } #[test] - fn closing_a_view_preserves_terminal_side_tables_until_explicit_close() { + fn resource_layout_undo_preview_does_not_enter_the_durable_journal() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - // A second tab keeps the pane live after the terminal view detaches. - let second = mux.new_tab(Some(pane), None, None).unwrap(); - let host = - mux.resource_terminal_host_identity(&first).expect("test terminal has a host identity"); - - mux.report_agent( - first.id, - AgentState::Working, - AgentSource::Socket, - Some("conf".to_string()), - ) - .unwrap(); - mux.post_notification( - "Build".to_string(), - "ok".to_string(), - NotificationLevel::Warning, - Some(first.id), - ) - .unwrap(); - assert_eq!(mux.list_agents(Some(first.id), None).len(), 1); - assert!(mux.surface_notification(first.id).is_some()); + let first = mux.new_browser_tab("about:blank#first".into(), None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right_terminal = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right_terminal.id).unwrap()); + let right = mux + .new_browser_tab("about:blank#right".into(), Some(right_pane), Some((38, 22))) + .unwrap(); + assert!(mux.close_surface(right_terminal.id).unwrap()); + let (selectors, before_screen) = { + let registry = mux.workspace_registry.lock().unwrap(); + let state = mux.state.lock().unwrap(); + let (workspace, screen) = state.screen_of(right_pane).unwrap(); + ( + crate::ResourceSelectors { + machine: Some(registry.machine_id().to_string()), + session: Some(registry.session_id().to_string()), + workspace: Some(state.workspaces[workspace].public_id.to_string()), + screen: Some(state.workspaces[workspace].screens[screen].public_id.to_string()), + ..crate::ResourceSelectors::default() + }, + ( + state.workspaces[workspace].screens[screen].layout_revision, + format!("{:?}", state.workspaces[workspace].screens[screen].layout_snapshot()), + format!("{:?}", state.workspaces[workspace].screens[screen].layout_undo), + ), + ) + }; + let fields = serde_json::json!({"confirm_close":false}).as_object().unwrap().clone(); + let fingerprint = serde_json::json!({ + "operation":"screen.layout.undo", + "selectors":selectors, + "fields":fields, + }); + let mutation = WorkspaceMutation::new("read-only-undo-preview", "test").unwrap(); + let before_registry = + mux.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); - mux.close_surface(first.id).unwrap(); + let error = mux + .resource_topology_operation( + ResourceOperation::ScreenLayoutUndo, + selectors.clone(), + fields, + Some(before_registry.revision), + &mutation, + ) + .unwrap_err(); - assert_eq!(mux.list_agents(Some(first.id), None).len(), 1); - assert_eq!(mux.list_agents(None, None).len(), 1); - assert!(mux.surface_notification(first.id).is_some()); - assert!(mux.surface(first.id).is_some()); - assert!(mux.with_state(|state| state.surfaces.contains_key(&second.id))); + let preview = error.downcast_ref::().unwrap(); + assert_eq!(preview.code, "confirmation.required"); + assert_eq!(preview.details["revision"], before_registry.revision.to_string()); + let confirmation_token = + preview.details["confirmation_token"].as_str().unwrap().to_string(); + assert_eq!(confirmation_token.len(), 64); + assert!(confirmation_token.bytes().all(|byte| byte.is_ascii_hexdigit())); + mux.with_state(|state| { + let (workspace, screen) = state.screen_of(right_pane).unwrap(); + let screen = &state.workspaces[workspace].screens[screen]; + assert_eq!(screen.layout_revision, before_screen.0); + assert_eq!(format!("{:?}", screen.layout_snapshot()), before_screen.1); + assert_eq!(format!("{:?}", screen.layout_undo), before_screen.2); + }); + let registry = mux.workspace_registry.lock().unwrap(); + assert_eq!(registry.resource_topology_snapshot().unwrap(), before_registry); + assert!( + registry.resource_events_after(before_registry.revision).unwrap().batches.is_empty() + ); + assert!( + registry + .lookup_resource_effect(&mutation.id, "screen.layout.undo", &fingerprint,) + .unwrap() + .is_none() + ); + drop(registry); - let detached_report = mux - .report_agent( - first.id, - AgentState::Blocked, - AgentSource::Hook, - Some("detached-hook".to_string()), + let missing_token_fields = + serde_json::json!({"confirm_close":true}).as_object().unwrap().clone(); + let missing_token_fingerprint = serde_json::json!({ + "operation":"screen.layout.undo", + "selectors":selectors, + "fields":missing_token_fields, + }); + let missing_token_mutation = + WorkspaceMutation::new("missing-token-undo-confirm", "test").unwrap(); + let missing_token = mux + .resource_topology_operation( + ResourceOperation::ScreenLayoutUndo, + selectors.clone(), + missing_token_fields, + Some(before_registry.revision), + &missing_token_mutation, ) - .expect("the terminal host identity remains valid without a view"); - assert_eq!(detached_report.state, AgentState::Blocked); - assert_eq!(detached_report.session.as_deref(), Some("detached-hook")); + .unwrap_err(); + let missing_token = missing_token.downcast_ref::().unwrap(); + assert_eq!(missing_token.code, "confirmation.required"); + assert_eq!(missing_token.details, preview.details); - mux.close_terminal(&host.terminal_id, &host.incarnation).unwrap(); - assert!(mux.list_agents(None, None).is_empty()); - assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); - assert_eq!( - crate::resource_api::public_session_snapshot(&mux).unwrap()["agents"] - .as_array() + let missing_revision_fields = serde_json::json!({ + "confirm_close":true, + "confirmation_token":confirmation_token, + }) + .as_object() + .unwrap() + .clone(); + let missing_revision_fingerprint = serde_json::json!({ + "operation":"screen.layout.undo", + "selectors":selectors, + "fields":missing_revision_fields, + }); + let missing_revision_mutation = + WorkspaceMutation::new("missing-revision-undo-confirm", "test").unwrap(); + let missing_revision = mux + .resource_topology_operation( + ResourceOperation::ScreenLayoutUndo, + selectors.clone(), + missing_revision_fields, + None, + &missing_revision_mutation, + ) + .unwrap_err(); + let missing_revision = missing_revision.downcast_ref::().unwrap(); + assert_eq!(missing_revision.code, "confirmation.required"); + assert_eq!(missing_revision.details, preview.details); + let registry = mux.workspace_registry.lock().unwrap(); + assert_eq!(registry.resource_topology_snapshot().unwrap(), before_registry); + assert!( + registry.resource_events_after(before_registry.revision).unwrap().batches.is_empty() + ); + assert!( + registry + .lookup_resource_effect( + &missing_token_mutation.id, + "screen.layout.undo", + &missing_token_fingerprint, + ) .unwrap() - .len(), - 1 + .is_none() ); - assert!(mux.surface_notification(first.id).is_none()); - assert!(mux.surface(first.id).is_none()); - } - - #[test] - fn replayed_host_close_does_not_acquire_public_resource_effects() { - let mux = test_mux(); - let surface = mux.new_workspace(None, None).unwrap(); - let host = mux - .resource_terminal_host_identity(&surface) - .expect("test terminal has a host identity"); - let public_id = match &surface.resource_identity().unwrap().content_id { - ContentPublicId::Terminal(public_id) => public_id.clone(), - ContentPublicId::Browser(_) => panic!("workspace opened a browser"), - }; - let mutation = WorkspaceMutation::new("lost-host-close-reply", "legacy-client").unwrap(); - let resource_revision = mux.with_state(|state| state.resource_revision); + assert!( + registry + .lookup_resource_effect( + &missing_revision_mutation.id, + "screen.layout.undo", + &missing_revision_fingerprint, + ) + .unwrap() + .is_none() + ); + drop(registry); - let host_close = mux - .workspace_registry - .lock() - .unwrap() - .close_terminal(&mutation, None, None, &host.terminal_id, Some(&host.incarnation)) - .unwrap(); - assert!(!host_close.replayed); - assert_eq!( - mux.workspace_registry.lock().unwrap().terminal_resource_id(&host.terminal_id).unwrap(), - Some(public_id.clone()) + let late_tab = mux.new_tab(Some(right_pane), None, Some((38, 22))).unwrap(); + let stale_fields = serde_json::json!({ + "confirm_close":true, + "confirmation_token":confirmation_token, + }) + .as_object() + .unwrap() + .clone(); + let stale_fingerprint = serde_json::json!({ + "operation":"screen.layout.undo", + "selectors":selectors, + "fields":stale_fields, + }); + let stale_mutation = WorkspaceMutation::new("stale-undo-confirm", "test").unwrap(); + let stale = mux + .resource_topology_operation( + ResourceOperation::ScreenLayoutUndo, + selectors.clone(), + stale_fields.clone(), + Some(before_registry.revision), + &stale_mutation, + ) + .unwrap_err(); + let refreshed = stale + .downcast_ref::() + .unwrap_or_else(|| panic!("stale confirmation returned an untyped error: {stale:#}")); + assert_eq!(refreshed.code, "confirmation.required"); + assert_ne!(refreshed.details["confirmation_token"], stale_fields["confirmation_token"]); + assert!(mux.surface(right.id).is_some()); + assert!(mux.surface(late_tab.id).is_some()); + assert!( + mux.workspace_registry + .lock() + .unwrap() + .lookup_resource_effect( + &stale_mutation.id, + "screen.layout.undo", + &stale_fingerprint, + ) + .unwrap() + .is_none() ); - let retry = mux - .close_terminal_with_mutation( - &host.terminal_id, - Some(&host.incarnation), - None, - None, - &mutation, + let refreshed_revision = + refreshed.details["revision"].as_str().unwrap().parse::().unwrap(); + let confirmed_fields = serde_json::json!({ + "confirm_close":true, + "confirmation_token":refreshed.details["confirmation_token"], + }) + .as_object() + .unwrap() + .clone(); + let committed = mux + .resource_topology_operation( + ResourceOperation::ScreenLayoutUndo, + selectors, + confirmed_fields, + Some(refreshed_revision), + &WorkspaceMutation::new("fresh-undo-confirm", "test").unwrap(), ) .unwrap(); - - assert_eq!(retry.terminal_revision, host_close.revision); - assert_eq!(mux.with_state(|state| state.resource_revision), resource_revision); - assert!(mux.surface(surface.id).is_some()); - assert_eq!( - mux.workspace_registry.lock().unwrap().terminal_resource_id(&host.terminal_id).unwrap(), - Some(public_id) - ); + assert!(!committed.replayed); + assert!(mux.surface(right.id).is_none()); + assert_terminal_view_detached(&mux, late_tab.id); } #[test] - fn replayed_resource_close_does_not_acquire_terminal_effects() { + fn layout_undo_confirmation_fences_exact_created_pane_tab_membership() { let mux = test_mux(); - let surface = mux.new_workspace(None, None).unwrap(); - let host = mux - .resource_terminal_host_identity(&surface) - .expect("test terminal has a host identity"); - let public_id = match &surface.resource_identity().unwrap().content_id { - ContentPublicId::Terminal(public_id) => public_id.clone(), - ContentPublicId::Browser(_) => panic!("workspace opened a browser"), + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + let LayoutUndoResult::ConfirmationRequired { revision, .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("pane creation undo must require confirmation"); }; - let host_close = mux - .workspace_registry - .lock() - .unwrap() - .close_terminal( - &WorkspaceMutation::new("closed-host", "legacy-client").unwrap(), - None, - None, - &host.terminal_id, - Some(&host.incarnation), - ) - .unwrap(); - let mutation = - WorkspaceMutation::new("lost-resource-close-reply", "resource-client").unwrap(); - let fingerprint = serde_json::json!({ - "op":"close-terminal", - "terminal_id":host.terminal_id, - "incarnation":host.incarnation, - }); - let resource_revision = mux.with_state(|state| state.resource_revision); - let resource_close = mux - .workspace_registry - .lock() - .unwrap() - .commit_resource_patch( - &mutation, - "terminal.close", - &fingerprint, - None, - Some(resource_revision), - &ResourcePatch { changes: Vec::new() }, - &serde_json::json!({}), - &serde_json::json!([]), - ) - .unwrap(); + let late_tab = mux.new_tab(Some(right_pane), None, Some((38, 22))).unwrap(); - let retry = mux - .close_terminal_with_mutation( - &host.terminal_id, - Some(&host.incarnation), - None, - None, - &mutation, - ) - .unwrap(); + let error = mux.undo_layout(right_pane, Some(revision), true).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(LayoutUndoError::Stale(message)) + if message.contains("layout revision conflict") + )); + assert!(mux.surface(right.id).is_some()); + assert!(mux.surface(late_tab.id).is_some()); - assert!(retry.already_closed); - assert_eq!(retry.terminal_revision, host_close.revision); - assert_eq!(mux.with_state(|state| state.resource_revision), resource_close.revision); - assert!(mux.surface(surface.id).is_some()); - assert_eq!( - mux.workspace_registry.lock().unwrap().terminal_resource_id(&host.terminal_id).unwrap(), - Some(public_id) - ); + let LayoutUndoResult::ConfirmationRequired { revision: refreshed, .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("a fresh preview must capture the new tab membership"); + }; + assert!(refreshed > revision); + assert!(matches!( + mux.undo_layout(right_pane, Some(refreshed), true).unwrap(), + LayoutUndoResult::Undone { .. } + )); + assert_terminal_view_detached(&mux, right.id); + assert_terminal_view_detached(&mux, late_tab.id); } #[test] - fn failed_browser_surface_attach_kills_worker() { - let mux = test_mux(); - let opts = mux.surface_options.lock().unwrap().clone(); - let surface = browser::new_surface( - 999, - "https://example.test".to_string(), - (10, 5), - (8, 16), - &opts, - Arc::downgrade(&mux), - ) - .unwrap(); - let browser = surface.as_browser().expect("browser surface"); - let done = browser.take_worker_done_for_test(); + fn layout_undo_confirmation_and_tab_creation_commit_atomically() { + for _ in 0..16 { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let LayoutUndoResult::ConfirmationRequired { revision, .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("pane creation undo must require confirmation"); + }; + let start = Arc::new(std::sync::Barrier::new(3)); + let tab = { + let mux = mux.clone(); + let start = start.clone(); + std::thread::spawn(move || { + start.wait(); + mux.new_tab(Some(right_pane), None, Some((38, 22))) + }) + }; + let undo = { + let mux = mux.clone(); + let start = start.clone(); + std::thread::spawn(move || { + start.wait(); + mux.undo_layout(right_pane, Some(revision), true) + }) + }; + start.wait(); + let tab = tab.join().unwrap(); + let undo = undo.join().unwrap(); - assert!(matches!( - mux.attach_browser_surface_to_pane_or_kill(123_456, &surface, 1), - BrowserSurfaceAttach::MissingPane - )); - assert!(browser.is_dead()); - done.recv_timeout(Duration::from_secs(1)) - .expect("browser worker exited after failed attach"); + match (tab, undo) { + (Ok(tab), Err(error)) => { + assert!(matches!( + error.downcast_ref::(), + Some(LayoutUndoError::Stale(message)) + if message.contains("layout revision conflict") + )); + assert!(mux.surface(right.id).is_some()); + assert!(mux.surface(tab.id).is_some()); + } + (Err(_), Ok(LayoutUndoResult::Undone { .. })) => { + assert_terminal_view_detached(&mux, right.id); + } + (tab, undo) => { + panic!("tab creation and confirmed undo partially committed: {tab:?}, {undo:?}") + } + } + } } #[test] - fn notification_sets_unread_and_clears_when_tab_is_viewed() { + fn layout_undo_public_edge_failures_are_typed() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.new_tab(Some(pane), None, None).unwrap(); - let notification = mux - .post_notification( - "Build".to_string(), - "ok".to_string(), - NotificationLevel::Warning, - Some(first.id), - ) - .unwrap(); + let unknown_pane = mux.undo_layout(u64::MAX, None, false).unwrap_err(); - let state = mux.surface_notification(first.id).unwrap(); - assert_eq!(state.notification, notification); - assert_eq!(state.level, NotificationLevel::Warning); - assert!(state.unread); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let LayoutUndoResult::ConfirmationRequired { .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("pane creation undo must require confirmation"); + }; + let missing_revision = mux.undo_layout(right_pane, None, true).unwrap_err(); - mux.select_tab(Some(pane), Some(1), None); - assert!(mux.surface_notification(first.id).is_some()); - mux.select_tab(Some(pane), Some(0), None); - assert!(mux.surface_notification(first.id).is_none()); - assert!(mux.surface_notification(second.id).is_none()); + assert_eq!( + [ + matches!( + unknown_pane.downcast_ref::(), + Some(LayoutUndoError::Stale(_)) + ), + matches!( + missing_revision.downcast_ref::(), + Some(LayoutUndoError::Stale(_)) + ), + ], + [true, true], + "public layout-undo edge failures must preserve their typed error" + ); } #[test] - fn browser_notification_is_owned_by_its_placement_and_clears_when_viewed() { + fn layout_undo_coalesces_resize_and_fences_pane_closure() { let mux = test_mux(); - let terminal = mux.new_workspace(None, None).unwrap(); - let pane = mux.with_state(|state| state.pane_of(terminal.id).unwrap()); - let browser = - mux.new_browser_tab("about:blank#notification".into(), Some(pane), None).unwrap(); - mux.select_tab(Some(pane), Some(0), None); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let notification = mux - .post_notification( - "Browser".into(), - "ready".into(), - NotificationLevel::Info, - Some(browser.id), - ) - .unwrap(); - let unread = mux.surface_notification(browser.id).unwrap(); - assert_eq!(unread.notification, notification); - assert!(unread.unread); - assert!(mux.surface_notification(terminal.id).is_none()); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.6, 7, 11)); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert!( + screen + .layout_snapshot_for_coalescing_change(Some(LayoutMutationKey::Resize { + owner: LayoutResizeOwner::ControlClient(7), + transaction: 11, + })) + .is_none(), + "continuation samples must reuse the transaction's first snapshot" + ); + }); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 7, 11)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns[1].width, 0.5); + }); - mux.select_tab(Some(pane), Some(1), None); - assert!(mux.surface_notification(browser.id).is_none()); + let LayoutUndoResult::ConfirmationRequired { revision, closes_panes, .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("pane creation undo must require confirmation"); + }; + assert_eq!(closes_panes, vec![right_pane]); + assert!( + mux.undo_layout(right_pane, None, true) + .unwrap_err() + .to_string() + .contains("requires the preview revision") + ); + assert!( + mux.undo_layout(right_pane, Some(revision.saturating_sub(1)), true) + .unwrap_err() + .to_string() + .contains("revision conflict") + ); + assert!(mux.surface(right.id).is_some()); + + assert!(matches!( + mux.undo_layout(right_pane, Some(revision), true).unwrap(), + LayoutUndoResult::Undone { .. } + )); + assert_terminal_view_detached(&mux, right.id); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert!(!screen.layout_columns_active()); + assert!(screen.viewport_splits.is_empty()); + assert_eq!(screen.root.pane_ids_vec(), vec![first_pane]); + assert_eq!(screen.active_pane, first_pane); + assert!(screen.layout_column_projection_is_consistent()); + }); } #[test] - fn notification_to_shared_default_surface_waits_for_explicit_view_acknowledgement() { + fn layout_undo_preserves_focus_when_the_current_pane_survives() { let mux = test_mux(); - let events = mux.subscribe(); - let surface = mux.new_workspace(None, None).unwrap(); - assert_eq!(mux.active_surface(), Some(surface.id)); - - let notification = mux - .post_notification( - "Build".to_string(), - "ok".to_string(), - NotificationLevel::Info, - Some(surface.id), - ) - .unwrap(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let unread = mux.surface_notification(surface.id).unwrap(); - assert_eq!(unread.notification, notification); - assert!(unread.unread); - assert!(events.try_iter().any(|event| { - matches!( - event, - MuxEvent::Notification(note) - if note.notification == notification && note.surface == Some(surface.id) - ) - })); + assert!(mux.focus_pane(first_pane)); + assert!(mux.set_viewport_pane_width(right_pane, 0.7)); + assert!(mux.focus_pane(right_pane)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); - mux.select_tab(None, Some(0), None); - assert!(mux.surface_notification(surface.id).is_none()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, right_pane); + assert_eq!(screen.layout_columns[1].width, 0.5); + }); } #[test] - fn notification_ledger_is_bounded_newest_first_and_uses_public_ids() { + fn layout_undo_restores_focus_to_the_restored_zoomed_pane() { let mux = test_mux(); - let terminal = mux.new_workspace(None, None).unwrap(); - let terminal_id = terminal.terminal_public_id().cloned().unwrap(); - mux.post_notification( - "attached".into(), - "terminal".into(), - NotificationLevel::Info, - Some(terminal.id), - ) - .unwrap(); - assert_eq!(mux.resource_notifications(1)[0].terminal_id, Some(terminal_id)); - - for index in 0..300 { - mux.post_notification( - format!("notice-{index}"), - String::new(), - NotificationLevel::Info, - None, - ) - .unwrap(); - } - let notifications = mux.resource_notifications(1_000); - assert_eq!(notifications.len(), 256); - assert_eq!(notifications.first().unwrap().title, "notice-299"); - assert_eq!(notifications.last().unwrap().title, "notice-44"); - assert_eq!( - notifications.iter().map(|notification| ¬ification.id).collect::>().len(), - 256 - ); - assert!( - notifications.windows(2).all(|pair| pair[0].created_at_ms >= pair[1].created_at_ms) - ); - } + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - fn seed_split_ratio_tree(mux: &Arc) -> (PaneId, PaneId, PaneId, SplitId, SplitId) { - let first = mux.new_workspace(None, None).unwrap(); - let p1 = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.split(p1, SplitDir::Right, None).unwrap(); - let p2 = mux.with_state(|state| state.pane_of(second.id).unwrap()); - let third = mux.split(p1, SplitDir::Right, None).unwrap(); - let p3 = mux.with_state(|state| state.pane_of(third.id).unwrap()); - let (root_split, inner_split) = mux.with_state(|state| { - let Node::Split { id: root, a, .. } = &state.workspaces[0].screens[0].root else { - panic!("root should be split"); - }; - let Node::Split { id: inner, .. } = a.as_ref() else { - panic!("first child should be split"); - }; - (*root, *inner) + assert!(mux.focus_pane(first_pane)); + mux.zoom_pane(Some(first_pane), ZoomMode::On).unwrap(); + mux.zoom_pane(Some(first_pane), ZoomMode::Off).unwrap(); + assert!(mux.focus_pane(right_pane)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.zoomed_pane, Some(first_pane)); + assert_eq!(screen.active_pane, first_pane); + assert_eq!(state.active_pane(), Some(first_pane)); }); - (p1, p2, p3, root_split, inner_split) } - fn leaf_spec() -> LayoutSpec { - LayoutSpec::Leaf(LayoutLeafSpec { cwd: None, command: None }) - } + #[test] + fn layout_undo_preserves_inactive_stack_selection() { + let mux = test_mux(); + let applied = mux + .apply_layout( + None, + None, + &split_spec( + SplitDir::Right, + 0.5, + LayoutSpec::Stack { pane_count: 2, expanded_index: 0 }, + LayoutSpec::Stack { pane_count: 2, expanded_index: 0 }, + ), + Some((80, 22)), + ) + .unwrap(); + let [left_first, left_second, right_first, _right_second] = + applied.panes.iter().map(|pane| pane.pane).collect::>()[..] + else { + panic!("two two-pane stacks should create four panes"); + }; + { + let mut state = mux.state.lock().unwrap(); + let screen = &mut state.workspaces[0].screens[0]; + let root = std::mem::replace(&mut screen.root, Node::Leaf(0)); + let Node::Split { id, a, b, .. } = root else { + panic!("test layout should have two stack branches"); + }; + screen.layout_columns = vec![ + LayoutColumn { id: mux.next_id(), width: 1.0, root: *a, zellij_auto_layout: None }, + LayoutColumn { id, width: 0.5, root: *b, zellij_auto_layout: None }, + ]; + screen.sync_layout_column_projection(); + Mux::rebuild_split_screen_index(&mut state); + } + mux.commit_ordinary_full_resource_projection( + "test.viewport_stack.prepare", + serde_json::json!({}), + ) + .unwrap(); - fn split_spec(dir: SplitDir, ratio: f32, a: LayoutSpec, b: LayoutSpec) -> LayoutSpec { - LayoutSpec::Split { dir, ratio, a: Box::new(a), b: Box::new(b) } - } + assert!(mux.focus_pane(right_first)); + assert!(mux.set_viewport_pane_width(right_first, 0.7)); + assert!(mux.focus_pane(left_second)); + assert!(mux.focus_pane(right_first)); + assert!(matches!( + mux.undo_layout(right_first, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); - fn node_shape(node: &Node) -> String { - match node { - Node::Leaf(_) => "leaf".to_string(), - Node::Split { dir, ratio, a, b, .. } => { - let dir = match dir { - SplitDir::Right => "right", - SplitDir::Down => "down", - }; - format!("{dir}:{ratio:.2}({}, {})", node_shape(a), node_shape(b)) - } - Node::Stack { panes, expanded } => format!("stack:{panes:?}:{expanded}"), - } + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, right_first); + assert!(matches!( + &screen.layout_columns[0].root, + Node::Stack { expanded, .. } if *expanded == left_second + )); + assert!(!matches!( + &screen.layout_columns[0].root, + Node::Stack { expanded, .. } if *expanded == left_first + )); + assert!(matches!( + &screen.root, + Node::Split { a, .. } + if matches!( + a.as_ref(), + Node::Stack { expanded, .. } if *expanded == left_second + ) + )); + assert!(screen.layout_column_projection_is_consistent()); + }); } - fn spec_shape(spec: &LayoutSpec) -> String { - match spec { - LayoutSpec::Leaf(_) => "leaf".to_string(), - LayoutSpec::Split { dir, ratio, a, b } => { - let dir = match dir { - SplitDir::Right => "right", - SplitDir::Down => "down", - }; - format!( - "{dir}:{:.2}({}, {})", - clamp_split_ratio(*ratio), - spec_shape(a), - spec_shape(b) - ) - } - LayoutSpec::Stack { pane_count, expanded_index } => { - format!("stack:{pane_count}:{expanded_index}") - } - } - } + #[test] + fn layout_undo_coalesces_every_target_in_one_resize_transaction() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let bottom = mux.split(right_pane, SplitDir::Down, Some((38, 10))).unwrap(); + let bottom_pane = mux.with_state(|state| state.pane_of(bottom.id).unwrap()); + let (split, initial_ratio, initial_undo_len) = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let Node::Split { id, ratio, .. } = &screen.layout_columns[1].root else { + panic!("right viewport column must contain the vertical split"); + }; + (*id, *ratio, screen.layout_undo.len()) + }); - fn leaf_order(node: &Node) -> Vec { - let mut ids = Vec::new(); - node.pane_ids(&mut ids); - ids - } + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 9, 41)); + assert!(mux.set_split_ratio_in_transaction_checked(split, 0.7, 9, 41).is_ok()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_undo.len(), initial_undo_len + 1); + }); - fn screen_root(mux: &Mux, screen: ScreenId) -> Node { - mux.with_state(|s| { - s.workspaces - .iter() - .flat_map(|ws| ws.screens.iter()) - .find(|candidate| candidate.id == screen) - .unwrap() - .root - .clone() - }) + assert!(matches!( + mux.undo_layout(bottom_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns[1].width, 0.5); + let Node::Split { ratio, .. } = &screen.layout_columns[1].root else { + panic!("right viewport column must retain the vertical split"); + }; + assert!((*ratio - initial_ratio).abs() < f32::EPSILON); + assert_eq!(screen.layout_undo.len(), initial_undo_len); + }); } #[test] - fn apply_layout_round_trip_reproduces_tree_shape_and_ratios() { + fn layout_undo_separates_resize_transactions_and_clients() { let mux = test_mux(); - let spec = split_spec( - SplitDir::Right, - 0.33, - leaf_spec(), - split_spec(SplitDir::Down, 0.67, leaf_spec(), leaf_spec()), - ); - let first = mux.apply_layout(None, Some("round-trip".into()), &spec, None).unwrap(); - let exported_shape = node_shape(&screen_root(&mux, first.screen)); - mux.with_state(|state| assert_eq!(state.workspaces[0].name, "0")); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let round_trip_spec = mux.with_state(|s| { - fn from_node(node: &Node) -> LayoutSpec { - match node { - Node::Leaf(_) => leaf_spec(), - Node::Split { dir, ratio, a, b, .. } => { - split_spec(*dir, *ratio, from_node(a), from_node(b)) - } - Node::Stack { panes, expanded } => LayoutSpec::Stack { - pane_count: panes.len(), - expanded_index: panes - .iter() - .position(|pane| pane == expanded) - .expect("valid stack expansion"), - }, - } - } - from_node(&s.workspaces[0].screens[0].root) + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.6, 1, 1)); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 1, 1)); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.8, 1, 2)); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.65, 2, 2)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.8); }); - let second = - mux.apply_layout(None, Some("round-trip-2".into()), &round_trip_spec, None).unwrap(); - let applied_shape = node_shape(&screen_root(&mux, second.screen)); - assert_eq!(exported_shape, spec_shape(&spec)); - assert_eq!(applied_shape, exported_shape); - assert_eq!(first.panes.len(), 3); - assert_eq!(second.panes.len(), 3); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.7); + }); + + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.5); + }); } #[test] - fn apply_layout_holds_target_workspace_lifecycle_through_commit() { + fn layout_undo_separates_in_process_and_control_resize_owners_with_same_ids() { let mux = test_mux(); - let target = mux.create_empty_workspace(Some("target".into()), None, None).unwrap(); - let (reserved_tx, reserved_rx) = std::sync::mpsc::sync_channel(1); - let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); - let release_rx = Arc::new(Mutex::new(release_rx)); - *mux.layout_apply_after_workspace_reservation.lock().unwrap() = Some(Arc::new({ - move || { - reserved_tx.send(()).unwrap(); - release_rx.lock().unwrap().recv().unwrap(); - } - })); - let apply = std::thread::spawn({ - let mux = mux.clone(); - move || mux.apply_layout(Some(target.workspace), None, &leaf_spec(), None) + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + // These model independent in-process and control-client entrypoints + // that happen to allocate the same numeric owner and transaction ids. + assert!( + mux.set_viewport_pane_width_in_process_transaction_checked(right_pane, 0.6, 1, 1) + .is_ok() + ); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 1, 1)); + + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.6); }); - reserved_rx.recv().unwrap(); - let (close_done_tx, close_done_rx) = std::sync::mpsc::sync_channel(1); - let close = std::thread::spawn({ - let mux = mux.clone(); - move || { - close_done_tx - .send(mux.close_workspace_at_revision(target.workspace, Some(1))) - .unwrap(); - } + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.5); }); - let premature_close = close_done_rx.recv_timeout(Duration::from_millis(250)); - let closed_early = premature_close.is_ok(); - release_tx.send(()).unwrap(); - let applied = apply.join(); - let close_result = match premature_close { - Ok(result) => result, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => close_done_rx.recv().unwrap(), - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - panic!("workspace close result channel disconnected") - } - }; - close.join().unwrap(); - *mux.layout_apply_after_workspace_reservation.lock().unwrap() = None; + } - assert!(!closed_early, "workspace closed before layout commit"); - assert!(applied.unwrap().is_ok()); - assert_eq!(close_result.unwrap(), Some(2)); - mux.shutdown(); + #[test] + fn in_process_resize_owner_allocation_is_mux_scoped() { + let first = test_mux(); + assert_eq!(first.allocate_in_process_resize_owner(), 1); + assert_eq!(first.allocate_in_process_resize_owner(), 2); + + let second = test_mux(); + assert_eq!(second.allocate_in_process_resize_owner(), 1); } #[test] - fn apply_layout_constructs_stack_with_requested_expansion() { + fn layout_undo_separates_a_new_resize_from_pre_undo_history() { let mux = test_mux(); - let applied = mux - .apply_layout( - None, - Some("stack".into()), - &LayoutSpec::Stack { pane_count: 3, expanded_index: 1 }, - None, - ) - .unwrap(); - let root = screen_root(&mux, applied.screen); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + assert!(mux.set_viewport_pane_width(right_pane, 0.6)); + assert!(mux.set_viewport_pane_width(first_pane, 0.9)); assert!(matches!( - root, - Node::Stack { ref panes, expanded } - if panes.len() == 3 && expanded == applied.panes[1].pane + mux.undo_layout(first_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + + assert!(mux.set_viewport_pane_width(right_pane, 0.7)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } )); mux.with_state(|state| { - assert_eq!(state.workspaces[0].screens[0].active_pane, applied.panes[1].pane); + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns[0].width, 1.0); + assert_eq!(screen.layout_columns[1].width, 0.6); }); } #[test] - fn pane_neighbor_returns_directional_adjacency() { + fn closing_a_pane_invalidates_layout_undo_history() { let mux = test_mux(); - let applied = mux - .apply_layout( - None, - None, - &split_spec( - SplitDir::Right, - 0.5, - leaf_spec(), - split_spec(SplitDir::Down, 0.5, leaf_spec(), leaf_spec()), - ), - None, - ) - .unwrap(); - let p1 = applied.panes[0].pane; - let p2 = applied.panes[1].pane; - let p3 = applied.panes[2].pane; + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - assert_eq!(mux.pane_neighbor(p1, Direction::Right).unwrap(), Some(p2)); - assert_eq!(mux.pane_neighbor(p2, Direction::Down).unwrap(), Some(p3)); - assert_eq!(mux.pane_neighbor(p1, Direction::Left).unwrap(), None); + assert!(mux.close_pane(right_pane).unwrap()); + let error = mux.undo_layout(first_pane, None, false).unwrap_err(); + + assert_eq!(error.to_string(), "no layout change to undo"); + assert_terminal_view_detached(&mux, right.id); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert!(!screen.layout_columns_active()); + assert!(screen.layout_column_projection_is_consistent()); + }); } #[test] - fn focus_direction_moves_active_pane() { + fn closing_the_base_viewport_column_preserves_the_promoted_width() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let p1 = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.split(p1, SplitDir::Right, None).unwrap(); - let p2 = mux.with_state(|state| state.pane_of(second.id).unwrap()); - assert!(mux.focus_pane(p1)); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); + let right = mux.new_pane_right(middle_pane, 0.4, Some((30, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + assert!(mux.set_viewport_pane_width(first_pane, 0.75)); - assert_eq!(mux.focus_direction(None, Direction::Right).unwrap(), p2); - mux.with_state(|s| assert_eq!(s.workspaces[0].screens[0].active_pane, p2)); - assert!(mux.focus_direction(None, Direction::Right).is_err()); + assert!(mux.close_pane(first_pane).unwrap()); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, Some(0.5)); + assert_eq!( + screen.root.viewport_column_owner(middle_pane, &screen.viewport_splits), + Some(ViewportColumn::Base) + ); + let ViewportColumn::Split(right_split) = + screen.root.viewport_column_owner(right_pane, &screen.viewport_splits).unwrap() + else { + panic!("right pane must remain an appended column"); + }; + assert_eq!(screen.viewport_splits[&right_split], 0.4); + let Node::Split { ratio, .. } = &screen.root else { + panic!("two viewport columns must retain one split"); + }; + assert!((*ratio - (0.5 / 0.9)).abs() < 0.0001); + }); } #[test] - fn focus_direction_returns_to_most_recently_focused_adjacent_pane() { + fn closing_a_middle_viewport_column_recomputes_fallback_ratios() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let left = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.split(left, SplitDir::Right, None).unwrap(); - let top_right = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let bottom = mux.split(top_right, SplitDir::Down, None).unwrap(); - let bottom_right = mux.with_state(|state| state.pane_of(bottom.id).unwrap()); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); + let right = mux.new_pane_right(middle_pane, 0.4, Some((30, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + assert!(mux.set_viewport_pane_width(first_pane, 0.75)); - assert!(mux.focus_pane(top_right)); - assert!(mux.focus_pane(bottom_right)); - assert_eq!(mux.focus_direction(None, Direction::Left).unwrap(), left); - assert_eq!(mux.focus_direction(None, Direction::Right).unwrap(), bottom_right); + assert!(mux.close_pane(middle_pane).unwrap()); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, Some(0.75)); + let ViewportColumn::Split(right_split) = + screen.root.viewport_column_owner(right_pane, &screen.viewport_splits).unwrap() + else { + panic!("right pane must remain appended"); + }; + assert_eq!(screen.viewport_splits.len(), 1); + assert_eq!(screen.viewport_splits[&right_split], 0.4); + let Node::Split { ratio, .. } = &screen.root else { + panic!("two viewport columns must retain one split"); + }; + assert!((*ratio - (0.75 / 1.15)).abs() < 0.0001); + }); } + #[cfg(unix)] #[test] - fn fresh_layout_focus_uses_layout_order_for_unfocused_candidates() { + fn discard_spawned_restores_unbound_running_terminal_when_registry_close_fails() { + const TERMINAL: &str = "00000000000040008000000000000012"; + const INCARNATION: &str = "10000000000040008000000000000012"; let mux = test_mux(); - let applied = mux - .apply_layout( - None, - None, - &split_spec( - SplitDir::Right, - 0.5, - leaf_spec(), - split_spec(SplitDir::Down, 0.5, leaf_spec(), leaf_spec()), - ), - None, + let workspace = mux + .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001012".into()), None) + .unwrap(); + let surface = + insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); + { + let mut state = mux.state.lock().unwrap(); + let (removed, split_index_dirty) = remove_surface(&mux, &mut state, surface.id); + if split_index_dirty { + Mux::rebuild_split_screen_index(&mut state); + } + insert_surface_checked( + &mux, + &mut state, + removed.expect("seeded terminal is removable from topology"), ) .unwrap(); + } + assert_eq!(mux.with_state(|state| state.pane_of(surface.id)), None); + let events = mux.subscribe(); + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); + + mux.discard_spawned(vec![surface.clone()]); + + let restored = mux + .with_state(|state| state.pane_of(surface.id)) + .expect("failed close must project the terminal back into reachable topology"); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Running + ); + assert!(events.try_iter().any(|event| { + matches!(event, MuxEvent::Status(message) + if message.contains("could not atomically close discarded terminals")) + })); + + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); + assert!(mux.close_pane_for_resource_effect(restored).unwrap()); + } + + #[cfg(unix)] + #[test] + fn close_pane_detaches_view_without_closing_terminal_host() { + const TERMINAL: &str = "00000000000040008000000000000010"; + const INCARNATION: &str = "10000000000040008000000000000010"; + let mux = test_mux(); + let workspace = mux + .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001002".into()), None) + .unwrap(); + let surface = + insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); + let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); - assert_eq!(mux.focus_direction(None, Direction::Right).unwrap(), applied.panes[1].pane); + assert!(mux.close_pane(pane).unwrap()); + assert_eq!(mux.with_state(|state| state.pane_of(surface.id)), None); + assert!(mux.surface(surface.id).is_some()); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Running + ); + assert!(mux.close_terminal(TERMINAL, INCARNATION).is_err()); + + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); + mux.close_terminal(TERMINAL, INCARNATION).unwrap(); + assert!(mux.surface(surface.id).is_none()); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Tombstoned + ); } + #[cfg(unix)] #[test] - fn selecting_a_tab_in_an_inactive_pane_does_not_change_focus_recency() { + fn close_screen_detaches_view_without_closing_terminal_host() { + const TERMINAL: &str = "00000000000040008000000000000011"; + const INCARNATION: &str = "10000000000040008000000000000011"; let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let left = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right_surface = mux.split(left, SplitDir::Right, None).unwrap(); - let right = mux.with_state(|state| state.pane_of(right_surface.id).unwrap()); - assert!(mux.focus_pane(left)); - mux.new_tab(Some(right), None, None).unwrap(); - let before = mux.with_state(|state| state.panes[&right].focused_at); + let workspace = mux + .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001003".into()), None) + .unwrap(); + let surface = + insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); + let screen = mux.with_state(|state| surface_screen_id(state, surface.id).unwrap()); + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); - mux.select_tab(Some(right), Some(0), None); + assert!(mux.close_screen(screen).unwrap()); + assert_eq!(mux.with_state(|state| surface_screen_id(state, surface.id)), None); + assert!(mux.surface(surface.id).is_some()); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Running + ); + assert!(mux.close_terminal(TERMINAL, INCARNATION).is_err()); - assert_eq!(mux.with_state(|state| state.panes[&right].focused_at), before); + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); + mux.close_terminal(TERMINAL, INCARNATION).unwrap(); + assert!(mux.surface(surface.id).is_none()); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Tombstoned + ); } #[test] - fn moving_a_tab_to_another_pane_stamps_the_new_focus() { + fn zellij_new_pane_uses_creation_order_after_manual_split() { let mux = test_mux(); let first = mux.new_workspace(None, None).unwrap(); - let left = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let extra = mux.new_tab(Some(left), None, None).unwrap(); - let right_surface = mux.split(left, SplitDir::Right, None).unwrap(); - let right = mux.with_state(|state| state.pane_of(right_surface.id).unwrap()); - assert!(mux.focus_pane(left)); - let before = mux.with_state(|state| state.panes[&right].focused_at); - - assert!(mux.move_tab(extra.id, right, 0)); + let p1 = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_pane(p1, None).unwrap(); + let p2 = mux.with_state(|state| state.pane_of(second.id).unwrap()); + let third = mux.split(p1, SplitDir::Down, None).unwrap(); + let p3 = mux.with_state(|state| state.pane_of(third.id).unwrap()); + let fourth = mux.new_pane(p3, None).unwrap(); + let p4 = mux.with_state(|state| state.pane_of(fourth.id).unwrap()); mux.with_state(|state| { - assert_eq!(state.active_pane(), Some(right)); - assert!(state.panes[&right].focused_at > before); + let screen = &state.workspaces[0].screens[0]; + let mut order = Vec::new(); + screen.root.pane_ids(&mut order); + assert_eq!(order, vec![p1, p2, p3, p4]); + assert_eq!(screen.zellij_auto_layout.as_deref(), Some(order.as_slice())); }); } #[test] - fn swap_pane_exchanges_leaf_positions_and_preserves_surfaces() { + fn failed_new_pane_attachment_retains_shutdown_ownership() { let mux = test_mux(); - let applied = mux - .apply_layout( - None, - None, - &split_spec(SplitDir::Right, 0.5, leaf_spec(), leaf_spec()), - None, - ) - .unwrap(); - let p1 = applied.panes[0].pane; - let s1 = applied.panes[0].surface; - let p2 = applied.panes[1].pane; - let s2 = applied.panes[1].surface; - assert_eq!(leaf_order(&screen_root(&mux, applied.screen)), vec![p1, p2]); + let first = mux.new_workspace(None, None).unwrap(); + let target = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let (spawned_tx, spawned_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let release_rx = Arc::new(Mutex::new(release_rx)); + *mux.new_pane_after_spawn.lock().unwrap() = Some(Arc::new({ + move |surface| { + surface.set_server_shutdown_failure_for_test(true); + spawned_tx.send(surface).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + } + })); - assert!(mux.swap_panes(p1, p2)); - assert_eq!(leaf_order(&screen_root(&mux, applied.screen)), vec![p2, p1]); - mux.with_state(|s| { - assert_eq!(s.panes[&p1].tabs, vec![s1]); - assert_eq!(s.panes[&p2].tabs, vec![s2]); + let create = std::thread::spawn({ + let mux = mux.clone(); + move || mux.new_pane(target, None) }); - } - - #[test] - fn zoom_pane_toggles_screen_zoom_state() { - let mux = test_mux(); - let applied = mux - .apply_layout( - None, - None, - &split_spec(SplitDir::Right, 0.5, leaf_spec(), leaf_spec()), - None, - ) - .unwrap(); - let p2 = applied.panes[1].pane; + let abandoned = spawned_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(mux.close_pane_for_resource_effect(target).unwrap()); + release_tx.send(()).unwrap(); - let zoomed = mux.zoom_pane(Some(p2), ZoomMode::Toggle).unwrap(); - assert_eq!(zoomed.zoomed_pane, Some(p2)); - mux.with_state(|s| assert_eq!(s.workspaces[0].screens[0].zoomed_pane, Some(p2))); + let error = create.join().unwrap().unwrap_err(); + assert!(error.to_string().contains("pane disappeared before new pane attachment")); + assert!(mux.surface(abandoned.id).is_none()); + assert_eq!( + mux.shutdown_owners.len(), + 1, + "failed pane attachment discarded its process owner instead of staging a retry" + ); - let restored = mux.zoom_pane(Some(p2), ZoomMode::Toggle).unwrap(); - assert_eq!(restored.zoomed_pane, None); - mux.with_state(|s| assert_eq!(s.workspaces[0].screens[0].zoomed_pane, None)); + *mux.new_pane_after_spawn.lock().unwrap() = None; + abandoned.set_server_shutdown_failure_for_test(false); + let _ = mux.close_all_surfaces_for_shutdown(); + assert!(mux.shutdown_owners.is_empty()); } #[test] - fn process_info_metadata_is_recorded_for_spawned_surface() { + fn zellij_new_pane_exits_zoom_before_focusing_the_new_pane() { let mux = test_mux(); - let cwd = std::env::temp_dir().to_string_lossy().into_owned(); - let applied = mux - .apply_layout( - None, - None, - &LayoutSpec::Leaf(LayoutLeafSpec { - cwd: Some(cwd.clone()), - command: Some(vec!["echo".into(), "ok".into()]), - }), - None, - ) - .unwrap(); - let surface = mux.surface(applied.panes[0].surface).unwrap(); + let first = mux.new_workspace(None, None).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + mux.zoom_pane(Some(first_pane), ZoomMode::On).unwrap(); - assert_eq!(surface.process_id(), Some(surface.id as u32)); - assert_eq!(surface.spawn_command().as_deref(), Some("echo ok")); - assert_eq!(surface.spawn_cwd().as_deref(), Some(cwd.as_str())); + let new_surface = mux.new_pane(first_pane, None).unwrap(); + let new_pane = mux.with_state(|state| state.pane_of(new_surface.id).unwrap()); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, new_pane); + assert_eq!(screen.zoomed_pane, None); + }); } #[test] - fn split_and_close_collapses_tree() { + fn zellij_new_pane_emits_pane_added_delta_and_layout_change() { let mux = test_mux(); - let s1 = mux.new_workspace(None, None).unwrap(); - let p1 = mux.with_state(|s| s.pane_of(s1.id).unwrap()); - let s2 = mux.split(p1, SplitDir::Right, None).unwrap(); - let p2 = mux.with_state(|s| s.pane_of(s2.id).unwrap()); - let s3 = mux.split(p2, SplitDir::Down, None).unwrap(); - let p3 = mux.with_state(|s| s.pane_of(s3.id).unwrap()); - - mux.with_state(|s| { - let mut ids = Vec::new(); - s.workspaces[0].screens[0].root.pane_ids(&mut ids); - assert_eq!(ids, vec![p1, p2, p3]); - }); - - mux.close_pane(p2).unwrap(); - mux.with_state(|s| { - let mut ids = Vec::new(); - s.workspaces[0].screens[0].root.pane_ids(&mut ids); - assert_eq!(ids, vec![p1, p3]); + let first = mux.new_workspace(None, None).unwrap(); + let (workspace, screen, first_pane) = mux.with_state(|state| { + let workspace = &state.workspaces[0]; + let screen = &workspace.screens[0]; + (workspace.id, screen.id, state.pane_of(first.id).unwrap()) }); + let events = mux.subscribe(); - mux.close_pane(p1).unwrap(); - mux.close_pane(p3).unwrap(); - assert_eq!(mux.surface_count(), 0); - mux.with_state(|s| { - assert_eq!(s.workspaces.len(), 1); - assert!(s.workspaces[0].screens.is_empty()); - assert_eq!(s.workspace_revision, 1); - }); - } + let added = mux.new_pane(first_pane, None).unwrap(); + let added_pane = mux.with_state(|state| state.pane_of(added.id).unwrap()); - #[test] - fn new_pane_right_wraps_the_screen_in_a_viewport_split() { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); - let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); + let deadline = Instant::now() + Duration::from_secs(1); + let mut saw_added = false; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + match events.recv_timeout(remaining).expect("pane creation events arrive") { + MuxEvent::TreeDelta(TreeDelta { + kind: TreeDeltaKind::PaneAdded, + workspace: event_workspace, + screen: Some(event_screen), + pane: Some(event_pane), + surface: None, + index: Some(1), + .. + }) if event_workspace == workspace + && event_screen == screen + && event_pane == added_pane => + { + saw_added = true; + } + MuxEvent::LayoutChanged(event_screen) if saw_added && event_screen == screen => { + break; + } + MuxEvent::LayoutChanged(_) if !saw_added => { + panic!("layout invalidation arrived before the pane-added delta") + } + _ => {} + } + } + assert!(events.try_iter().all(|event| !matches!(event, MuxEvent::TreeChanged))); + } - let appended = - mux.new_pane_right(second_pane, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); - let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); + #[test] + fn closing_zellij_pane_reapplies_layout_for_remaining_count() { + let mux = test_mux(); + let first = mux.new_workspace(None, None).unwrap(); + let mut surfaces = vec![first]; + let mut active = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); + for _ in 0..4 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + surfaces.push(surface); + } + mux.close_surface(surfaces[0].id).unwrap(); mux.with_state(|state| { let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.active_pane, appended_pane); - assert_eq!(screen.zoomed_pane, None); - assert_eq!(screen.viewport_splits.len(), 1); - let Node::Split { id, dir, ratio, a, b } = &screen.root else { - panic!("viewport pane must wrap the screen root"); - }; - assert_eq!(*dir, SplitDir::Right); - assert!((*ratio - 0.6).abs() < f32::EPSILON); - assert!(a.contains(first_pane)); - assert!(a.contains(second_pane)); - assert!(matches!(b.as_ref(), Node::Leaf(pane) if *pane == appended_pane)); - assert_eq!(screen.viewport_splits[id], DEFAULT_VIEWPORT_PANE_WIDTH); - - let layout = layout_screen_with_viewport( + let order = screen.zellij_auto_layout.as_ref().unwrap(); + assert_eq!(order.len(), 4); + let layout = layout_screen( &screen.root, - Rect { x: 0, y: 0, width: 80, height: 24 }, + Rect { x: 0, y: 0, width: 200, height: 40 }, Some(screen.active_pane), - screen.viewport_base_width.unwrap_or(1.0), - &screen.viewport_splits, - ); - assert_eq!(layout.virtual_width, 133); - assert_eq!(layout.rect_of(first_pane).unwrap().width, 40); - assert_eq!(layout.rect_of(second_pane).unwrap().width, 40); - assert_eq!( - layout.rect_of(appended_pane).unwrap(), - VirtualRect { x: 80, y: 0, width: 53, height: 24 } ); + assert_eq!(layout.rect_of(order[0]).unwrap().height, 40); + let right_heights = order[1..] + .iter() + .map(|pane| layout.rect_of(*pane).unwrap().height) + .collect::>(); + assert_eq!(right_heights, vec![13, 14, 13]); }); - assert_eq!(mux.pane_neighbor(second_pane, Direction::Right).unwrap(), Some(appended_pane)); - assert_eq!(mux.pane_neighbor(appended_pane, Direction::Left).unwrap(), Some(second_pane)); } #[test] - fn viewport_neighbor_navigation_remains_adjacent_past_u16_layout_extent() { + fn closing_zellij_stack_pane_keeps_active_pane_expanded() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut panes = vec![first_pane]; - - for _ in 0..12 { - let previous = *panes.last().unwrap(); - let surface = - mux.new_pane_right(previous, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); - panes.push(mux.with_state(|state| state.pane_of(surface.id).unwrap())); + let first = mux.new_workspace(None, None).unwrap(); + let mut surfaces = vec![first]; + let mut active = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); + for _ in 1..14 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + surfaces.push(surface); } + let leading_pane = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); + let active_stack_pane = mux.with_state(|state| state.pane_of(surfaces[2].id).unwrap()); + assert!(mux.focus_pane(active_stack_pane)); - for pair in panes.windows(2) { - let [left, right] = pair else { unreachable!() }; - assert_eq!(mux.pane_neighbor(*left, Direction::Right).unwrap(), Some(*right)); - assert_eq!(mux.pane_neighbor(*right, Direction::Left).unwrap(), Some(*left)); - assert_eq!(mux.pane_focus_neighbor(*left, Direction::Right).unwrap(), Some(*right)); - assert_eq!(mux.pane_focus_neighbor(*right, Direction::Left).unwrap(), Some(*left)); - } + mux.close_surface(surfaces[1].id).unwrap(); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, active_stack_pane); + assert!(matches!( + &screen.root, + Node::Split { dir: SplitDir::Right, a, b, .. } + if matches!(a.as_ref(), Node::Leaf(pane) if *pane == leading_pane) + && matches!(b.as_ref(), Node::Stack { panes, .. } if panes.contains(&active_stack_pane)) + )); + let layout = layout_screen( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 40 }, + Some(screen.active_pane), + ); + assert!(!layout.stacked_headers.contains(&active_stack_pane)); + assert!(layout.rect_of(active_stack_pane).unwrap().height > 1); + }); } #[test] - fn new_pane_right_rejects_invalid_width_before_spawning() { + fn rebuilding_zellij_layout_preserves_stack_expansion_while_focus_is_elsewhere() { let mux = test_mux(); let first = mux.new_workspace(None, None).unwrap(); - let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let surfaces = mux.with_state(|state| state.surfaces.len()); - - assert!(mux.new_pane_right(pane, 0.0, None).is_err()); - assert_eq!(mux.with_state(|state| state.surfaces.len()), surfaces); - } - - #[test] - fn new_pane_right_discards_spawn_when_target_disappears_before_attachment() { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let weak = Arc::downgrade(&mux); - *mux.viewport_split_after_spawn.lock().unwrap() = Some(Arc::new(move || { - weak.upgrade().unwrap().close_pane_for_resource_effect(first_pane).unwrap(); - })); - - let error = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap_err(); + let mut surfaces = vec![first]; + let mut active = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); + for _ in 1..14 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + surfaces.push(surface); + } + let leading_pane = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); + let expanded_stack_pane = mux.with_state(|state| state.pane_of(surfaces[2].id).unwrap()); + assert!(mux.focus_pane(expanded_stack_pane)); + assert!(mux.focus_pane(leading_pane)); - assert_eq!(error.to_string(), "pane creation failed"); + mux.close_surface(surfaces[1].id).unwrap(); mux.with_state(|state| { - assert!(state.surfaces.is_empty()); - assert!(state.panes.is_empty()); - assert!(state.workspaces[0].screens.is_empty()); + let screen = &state.workspaces[0].screens[0]; + let layout = layout_screen( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 40 }, + Some(screen.active_pane), + ); + assert!(!layout.stacked_headers.contains(&expanded_stack_pane)); + assert!(layout.rect_of(expanded_stack_pane).unwrap().height > 1); }); } #[test] - fn viewport_columns_resize_independently() { + fn moving_zellij_stack_pane_keeps_target_pane_expanded() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); - let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); - let appended = - mux.new_pane_right(second_pane, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); - let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); - - assert!(mux.set_viewport_pane_width(first_pane, 0.75)); - assert!(mux.set_viewport_pane_width(appended_pane, 0.5)); - assert!(!mux.set_viewport_pane_width(appended_pane, 0.0)); + let first = mux.new_workspace(None, None).unwrap(); + let mut surfaces = vec![first]; + let mut active = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); + for _ in 1..14 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + surfaces.push(surface); + } + let leading_pane = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); + let target = mux.with_state(|state| state.pane_of(surfaces[2].id).unwrap()); + let events = mux.subscribe(); + assert!(mux.move_tab(surfaces[1].id, target, 0)); mux.with_state(|state| { let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.viewport_base_width, Some(0.75)); - let appended_split = match screen - .root - .viewport_column_owner(appended_pane, &screen.viewport_splits) - .unwrap() - { - ViewportColumn::Split(split) => split, - ViewportColumn::Base => panic!("appended pane must own a viewport split"), - }; - assert_eq!(screen.viewport_splits[&appended_split], 0.5); - let Node::Split { ratio, .. } = &screen.root else { - panic!("viewport layout must retain its root split"); - }; - assert!((*ratio - 0.6).abs() < f32::EPSILON); - - let layout = layout_screen_with_viewport( + assert_eq!(screen.active_pane, target); + assert!(matches!( &screen.root, - Rect { x: 0, y: 0, width: 80, height: 24 }, + Node::Split { dir: SplitDir::Right, a, b, .. } + if matches!(a.as_ref(), Node::Leaf(pane) if *pane == leading_pane) + && matches!(b.as_ref(), Node::Stack { panes, .. } if panes.contains(&target)) + )); + let layout = layout_screen( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 40 }, Some(screen.active_pane), - screen.viewport_base_width.unwrap(), - &screen.viewport_splits, - ); - assert_eq!(layout.virtual_width, 100); - assert_eq!(layout.rect_of(first_pane).unwrap().width, 30); - assert_eq!(layout.rect_of(second_pane).unwrap().width, 30); - assert_eq!( - layout.rect_of(appended_pane).unwrap(), - VirtualRect { x: 60, y: 0, width: 40, height: 24 } ); + assert!(!layout.stacked_headers.contains(&target)); + assert!(layout.rect_of(target).unwrap().height > 1); }); + assert!(events.try_iter().any(|event| matches!(event, MuxEvent::LayoutChanged(_)))); } #[test] - fn projected_viewport_split_ratio_resizes_the_authoritative_column() { + fn swapping_zellij_stack_panes_keeps_active_pane_expanded() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first = mux.new_workspace(None, None).unwrap(); let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let appended = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); - let split = mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - match &screen.root { - Node::Split { id, .. } => *id, - _ => panic!("viewport layout must expose a compatibility split"), - } - }); + let mut active = first_pane; + for _ in 1..13 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + } - let events = mux.subscribe(); - assert!(mux.set_split_ratio_checked(split, 0.5).is_ok()); - assert!(matches!(events.recv().unwrap(), MuxEvent::LayoutChanged(_))); - let after_change = mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - (screen.layout_revision, screen.layout_undo.len()) - }); - assert!(mux.set_split_ratio_checked(split, 0.5).is_ok()); - assert!(matches!( - mux.set_split_ratio_checked(split, 0.25), - Err(LayoutRatioError::UnrepresentableViewportWidth { split: rejected, .. }) - if rejected == split - )); + assert!(mux.swap_panes(active, first_pane)); mux.with_state(|state| { let screen = &state.workspaces[0].screens[0]; - assert_eq!((screen.layout_revision, screen.layout_undo.len()), after_change); - assert_eq!(screen.viewport_splits[&split], 1.0); - assert_eq!( - screen - .layout_columns - .iter() - .find(|column| column.root.contains(appended_pane)) - .map(|column| column.width), - Some(1.0) - ); - assert!(matches!( + assert_eq!(screen.active_pane, active); + assert!(screen.zellij_auto_layout.is_none()); + let layout = layout_screen( &screen.root, - Node::Split { id, ratio, .. } - if *id == split && (*ratio - 0.5).abs() < f32::EPSILON - )); - assert!(state.split_screens.contains_key(&split)); + Rect { x: 0, y: 0, width: 80, height: 40 }, + Some(screen.active_pane), + ); + assert!(!layout.stacked_headers.contains(&active)); + assert!(layout.rect_of(active).unwrap().height > 1); }); - assert!(events.try_iter().next().is_none()); } - fn seed_high_ratio_viewport_projection() -> (Arc, PaneId, SplitId) { + #[test] + fn closing_active_pane_in_damaged_stack_expands_replacement() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first = mux.new_workspace(None, None).unwrap(); let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let middle = mux.new_pane_right(first_pane, 1.0, Some((78, 22))).unwrap(); - let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); - let right = - mux.new_pane_right(middle_pane, MIN_VIEWPORT_PANE_WIDTH, Some((6, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let split = mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - let Node::Split { id, ratio, .. } = &screen.root else { - panic!("three columns should expose a projected root split"); - }; - assert!((*ratio - (2.0 / 2.1)).abs() < f32::EPSILON); - *id - }); - (mux, right_pane, split) - } + let mut active_surface = first; + let mut active = first_pane; + for _ in 1..14 { + active_surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(active_surface.id).unwrap()); + } + assert!(mux.swap_panes(active, first_pane)); - fn assert_high_ratio_projection_resize_applied( - mux: &Mux, - expected_width: f32, - undo_count_before: usize, - ) { + mux.close_surface(active_surface.id).unwrap(); mux.with_state(|state| { let screen = &state.workspaces[0].screens[0]; - assert!( - (screen.layout_columns[2].width - expected_width).abs() < 1e-6, - "projected ratio should update authoritative width: {:?}", - screen.layout_columns + assert!(screen.zellij_auto_layout.is_none()); + let layout = layout_screen( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 40 }, + Some(screen.active_pane), ); - assert_eq!(screen.layout_undo.len(), undo_count_before + 1); - assert!(screen.layout_column_projection_is_consistent()); + assert!(!layout.stacked_headers.contains(&screen.active_pane)); + assert!(layout.rect_of(screen.active_pane).unwrap().height > 1); }); } #[test] - fn maximum_projected_split_ratio_still_resizes_authoritative_column() { - let (mux, _right_pane, split) = seed_high_ratio_viewport_projection(); - let expected_width = 2.0 * (1.0 - 0.95) / 0.95; - let undo_count_before = - mux.with_state(|state| state.workspaces[0].screens[0].layout_undo.len()); - let events = mux.subscribe(); - - assert!(mux.set_split_ratio_checked(split, 0.95).is_ok()); + fn focusing_zellij_stack_header_expands_that_pane() { + let mux = test_mux(); + let first = mux.new_workspace(None, None).unwrap(); + let mut active = mux.with_state(|state| state.pane_of(first.id).unwrap()); + for _ in 1..13 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + } + let stack_pane = mux.with_state(|state| { + state.workspaces[0].screens[0].zellij_auto_layout.as_ref().unwrap()[1] + }); - assert_high_ratio_projection_resize_applied(&mux, expected_width, undo_count_before); - assert!(matches!(events.try_recv(), Ok(MuxEvent::LayoutChanged(_)))); + assert!(mux.focus_pane(stack_pane)); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, stack_pane); + assert!(matches!( + &screen.root, + Node::Split { dir: SplitDir::Right, b, .. } + if matches!(b.as_ref(), Node::Stack { panes, .. } if panes.contains(&stack_pane)) + )); + let layout = layout_screen( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 40 }, + Some(screen.active_pane), + ); + assert!(!layout.stacked_headers.contains(&stack_pane)); + assert!(layout.rect_of(stack_pane).unwrap().height > 1); + }); } #[test] - fn maximum_pane_addressed_ratio_still_resizes_authoritative_column() { - let (mux, right_pane, _split) = seed_high_ratio_viewport_projection(); - let expected_width = 2.0 * (1.0 - 0.95) / 0.95; - let undo_count_before = - mux.with_state(|state| state.workspaces[0].screens[0].layout_undo.len()); + fn focusing_outside_a_stack_emits_layout_changed() { + let mux = test_mux(); + let first = mux.new_workspace(None, None).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut active = first_pane; + for _ in 1..13 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + } + let stack_pane = mux.with_state(|state| { + state.workspaces[0].screens[0].zellij_auto_layout.as_ref().unwrap()[1] + }); + let outside = mux.split(active, SplitDir::Right, None).unwrap(); + let outside_pane = mux.with_state(|state| state.pane_of(outside.id).unwrap()); + assert!(mux.focus_pane(stack_pane)); let events = mux.subscribe(); - assert!(mux.set_ratio_checked(right_pane, SplitDir::Right, 0.95).is_ok()); - - assert_high_ratio_projection_resize_applied(&mux, expected_width, undo_count_before); - assert!(matches!(events.try_recv(), Ok(MuxEvent::TreeChanged))); - assert!(matches!(events.try_recv(), Ok(MuxEvent::LayoutChanged(_)))); + assert!(mux.focus_pane(outside_pane)); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let layout = layout_screen( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 40 }, + Some(screen.active_pane), + ); + assert!(!layout.stacked_headers.contains(&stack_pane)); + assert!(layout.rect_of(stack_pane).unwrap().height > 1); + }); + let invalidations = events + .try_iter() + .filter(|event| matches!(event, MuxEvent::TreeChanged | MuxEvent::LayoutChanged(_))) + .collect::>(); + assert_eq!(invalidations.len(), 1); + assert!(matches!(invalidations[0], MuxEvent::LayoutChanged(_))); } #[test] - fn set_ratio_resizes_a_projected_viewport_split() { + fn directional_split_of_zellij_stack_preserves_requested_direction() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first = mux.new_workspace(None, None).unwrap(); let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let mut active = first_pane; + for _ in 1..13 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + } - assert!(mux.set_ratio_checked(first_pane, SplitDir::Right, 0.5).is_ok()); + let split = mux.split(active, SplitDir::Right, None).unwrap(); + let split_pane = mux.with_state(|state| state.pane_of(split.id).unwrap()); mux.with_state(|state| { let screen = &state.workspaces[0].screens[0]; - let Node::Split { id, ratio, .. } = &screen.root else { - panic!("viewport layout must expose a compatibility split"); - }; - assert!((*ratio - 0.5).abs() < f32::EPSILON); - assert_eq!(screen.viewport_splits[id], 1.0); - assert!(screen.layout_column_projection_is_consistent()); + assert!(matches!( + &screen.root, + Node::Split { dir: SplitDir::Right, a, b, .. } + if matches!(a.as_ref(), Node::Leaf(pane) if *pane == first_pane) + && matches!( + b.as_ref(), + Node::Split { dir: SplitDir::Right, a, b, .. } + if matches!(a.as_ref(), Node::Stack { .. }) + && matches!(b.as_ref(), Node::Leaf(pane) if *pane == split_pane) + ) + )); + assert!(screen.zellij_auto_layout.is_none()); }); } #[test] - fn new_pane_right_inserts_after_the_target_viewport_column() { + fn splitting_a_collapsed_stack_member_expands_the_target_side() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first = mux.new_workspace(None, None).unwrap(); let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); - let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); + let mut active = first_pane; + for _ in 1..13 { + let surface = mux.new_pane(active, None).unwrap(); + active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + } + let target = mux.with_state(|state| { + state.workspaces[0].screens[0].zellij_auto_layout.as_ref().unwrap()[1] + }); + mux.split(target, SplitDir::Right, None).unwrap(); mux.with_state(|state| { let screen = &state.workspaces[0].screens[0]; - let layout = layout_screen_with_viewport( + assert!(matches!( &screen.root, - Rect { x: 0, y: 0, width: 80, height: 24 }, - Some(screen.active_pane), - screen.viewport_base_width.unwrap(), - &screen.viewport_splits, - ); - assert_eq!( - layout.panes.iter().map(|(pane, _)| *pane).collect::>(), - vec![first_pane, middle_pane, second_pane] - ); - assert_eq!(layout.rect_of(first_pane).unwrap().x, 0); - assert_eq!(layout.rect_of(middle_pane).unwrap().x, 80); - assert_eq!(layout.rect_of(second_pane).unwrap().x, 120); + Node::Split { b, .. } + if matches!( + b.as_ref(), + Node::Split { a, .. } + if matches!(a.as_ref(), Node::Stack { expanded, .. } if *expanded == target) + ) + )); }); } #[test] - fn viewport_self_swap_is_a_noop_without_undo_history_or_events() { + fn structural_test_mux_can_create_many_surfaces_without_ptys() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let before = mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - (screen.layout_revision, screen.layout_undo.len(), format!("{:?}", screen.root)) + let first = mux.new_workspace(None, Some((120, 40))).unwrap(); + let pane = mux.with_state(|s| s.pane_of(first.id).unwrap()); + + for _ in 0..450 { + mux.new_tab(Some(pane), None, None).unwrap(); + } + + assert_eq!(mux.surface_count(), 451); + mux.with_state(|s| { + let pane = &s.panes[&pane]; + assert_eq!(pane.tabs.len(), 451); + for surface in pane.tabs.iter().filter_map(|id| s.surfaces.get(id)) { + assert_eq!(surface.kind(), SurfaceKind::Pty); + assert_eq!(surface.size(), (120, 40)); + assert!(!surface.is_dead()); + } }); + } + + #[test] + fn server_shutdown_clears_many_surfaces_with_one_tree_event() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((120, 40))).unwrap(); + mux.resize_surface_for_client(first.id, 1, 100, 30).unwrap(); + assert_eq!(mux.set_client_size_participation(first.id, 1, false), Some(true)); + mux.record_client_size(90, 28); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + for _ in 0..450 { + mux.new_tab(Some(pane), None, None).unwrap(); + } let events = mux.subscribe(); - assert!(!mux.swap_panes(right_pane, right_pane)); + assert_eq!(mux.close_all_surfaces_for_shutdown().unwrap(), 451); mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.layout_revision, before.0); - assert_eq!(screen.layout_undo.len(), before.1); - assert_eq!(format!("{:?}", screen.root), before.2); + assert!(state.surfaces.is_empty()); + assert!(state.panes.is_empty()); + assert!(state.split_screens.is_empty()); + assert!(state.workspaces.iter().all(|workspace| workspace.screens.is_empty())); }); - assert!(events.try_iter().next().is_none()); + let sizing = mux.client_sizing.lock().unwrap(); + assert!(sizing.surfaces.is_empty()); + assert!(sizing.report_order.is_empty()); + assert!(sizing.policies.is_empty()); + assert!(sizing.latest_explicit_size.is_none()); + drop(sizing); + let events = events.try_iter().collect::>(); + assert_eq!(events.iter().filter(|event| matches!(event, MuxEvent::TreeChanged)).count(), 1); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, MuxEvent::TerminalRegistryChanged { .. })) + .count(), + 1 + ); + assert_eq!(events.len(), 2); + let error = mux.new_workspace(None, None).unwrap_err(); + assert_eq!(error.to_string(), "server is shutting down"); + } + + #[test] + fn failed_surface_shutdown_retains_process_ownership_for_retry() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let owned = mux.surface(surface.id).unwrap(); + owned.set_server_shutdown_failure_for_test(true); + + let error = mux.close_all_surfaces_for_shutdown().unwrap_err(); + + assert!(error.to_string().contains("could not terminate 1 surface process")); + assert!(mux.surface(surface.id).is_none()); + assert_eq!(mux.shutdown_owners.len(), 1); + + owned.set_server_shutdown_failure_for_test(false); + assert_eq!(mux.close_all_surfaces_for_shutdown().unwrap(), 1); + assert!(mux.shutdown_owners.is_empty()); } #[test] - fn zellij_new_pane_rebalances_only_the_focused_layout_column() { + fn ordinary_mux_lifecycle_does_not_start_a_reconciler_worker() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + assert!(!mux.shutdown_owner_reconciler.worker_started()); - let right_added = mux.new_pane(right_pane, Some((38, 10))).unwrap(); - let right_added_pane = mux.with_state(|state| state.pane_of(right_added.id).unwrap()); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.layout_columns.len(), 2); - assert_eq!(screen.layout_columns[0].root.pane_ids_vec(), vec![first_pane]); - assert_eq!( - screen.layout_columns[1].root.pane_ids_vec(), - vec![right_pane, right_added_pane] - ); - assert_eq!( - screen.layout_columns[1].zellij_auto_layout.as_deref(), - Some([right_pane, right_added_pane].as_slice()) - ); - assert_eq!(screen.viewport_splits.len(), 1); - }); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + assert!(mux.close_surface(surface.id).unwrap()); - let left_added = mux.new_pane(first_pane, Some((38, 10))).unwrap(); - let left_added_pane = mux.with_state(|state| state.pane_of(left_added.id).unwrap()); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!( - screen.layout_columns[0].root.pane_ids_vec(), - vec![first_pane, left_added_pane] - ); - assert_eq!( - screen.layout_columns[1].root.pane_ids_vec(), - vec![right_pane, right_added_pane] - ); - assert_eq!(screen.viewport_base_width, Some(1.0)); - assert_eq!(screen.viewport_splits.values().copied().collect::>(), vec![0.5]); - assert!(screen.layout_column_projection_is_consistent()); - }); + assert!(!mux.shutdown_owner_reconciler.worker_started()); } #[test] - fn layout_undo_removes_only_the_pane_created_in_the_focused_column() { + fn ordinary_terminal_close_retains_failed_process_ownership_until_reconciled() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let added = mux.new_pane(right_pane, Some((38, 10))).unwrap(); - let added_pane = mux.with_state(|state| state.pane_of(added.id).unwrap()); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let owned = mux.surface(surface.id).unwrap(); + let identity = mux.resource_terminal_host_identity(&owned).unwrap(); + owned.set_server_shutdown_failure_for_test(true); - let LayoutUndoResult::ConfirmationRequired { revision, closes_panes, .. } = - mux.undo_layout(added_pane, None, false).unwrap() - else { - panic!("new pane undo must require confirmation"); - }; - assert_eq!(closes_panes, vec![added_pane]); - assert!(matches!( - mux.undo_layout(added_pane, Some(revision), true).unwrap(), - LayoutUndoResult::Undone { .. } - )); + mux.close_terminal(&identity.terminal_id, &identity.incarnation).unwrap(); + assert!(mux.surface(surface.id).is_none()); + assert_eq!(mux.shutdown_owners.len(), 1); - assert_terminal_view_detached(&mux, added.id); - assert!(mux.surface(right.id).is_some()); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.layout_columns.len(), 2); - assert_eq!(screen.layout_columns[0].root.pane_ids_vec(), vec![first_pane]); - assert_eq!(screen.layout_columns[1].root.pane_ids_vec(), vec![right_pane]); - assert!(screen.layout_column_projection_is_consistent()); - }); + owned.set_server_shutdown_failure_for_test(false); + mux.close_all_surfaces_for_shutdown().unwrap(); + assert!(mux.shutdown_owners.is_empty()); } #[test] - fn layout_undo_confirmation_preview_is_read_only() { + fn ordinary_terminal_close_reconciles_a_transient_termination_failure() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let before = mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - ( - screen.layout_revision, - format!("{:?}", screen.layout_snapshot()), - format!("{:?}", screen.layout_undo), - state.panes[&right_pane].tabs.clone(), - ) - }); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let owned = mux.surface(surface.id).unwrap(); + let identity = mux.resource_terminal_host_identity(&owned).unwrap(); + let (failing, attempts) = owned.set_recovering_server_shutdown_for_test(); - let first_preview = mux.undo_layout(right_pane, None, false).unwrap(); - let second_preview = mux.undo_layout(right_pane, None, false).unwrap(); + mux.close_terminal(&identity.terminal_id, &identity.incarnation).unwrap(); + assert_eq!(mux.shutdown_owners.len(), 1); + assert_eq!(attempts.load(), 1); - assert_eq!( - first_preview, - LayoutUndoResult::ConfirmationRequired { - screen: mux.with_state(|state| state.workspaces[0].screens[0].id), - revision: before.0, - closes_panes: vec![right_pane], - } + failing.store(false, Ordering::Release); + assert!( + mux.shutdown_owner_reconciler + .wait_until(Instant::now() + Duration::from_secs(2), || mux + .shutdown_owners + .is_empty(),), + "retained owner had no normal-lifecycle retry" ); - assert_eq!(second_preview, first_preview); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.layout_revision, before.0); - assert_eq!(format!("{:?}", screen.layout_snapshot()), before.1); - assert_eq!(format!("{:?}", screen.layout_undo), before.2); - assert_eq!(state.panes[&right_pane].tabs, before.3); - }); + + assert!(mux.shutdown_owners.is_empty(), "retained owner had no normal-lifecycle retry"); + assert!(attempts.load() >= 2); } #[test] - fn resource_layout_undo_preview_does_not_enter_the_durable_journal() { + fn retained_terminal_shutdown_owners_bound_future_surface_admission() { let mux = test_mux(); - let first = mux.new_browser_tab("about:blank#first".into(), None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right_terminal = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right_terminal.id).unwrap()); - let right = mux - .new_browser_tab("about:blank#right".into(), Some(right_pane), Some((38, 22))) - .unwrap(); - assert!(mux.close_surface(right_terminal.id).unwrap()); - let (selectors, before_screen) = { - let registry = mux.workspace_registry.lock().unwrap(); - let state = mux.state.lock().unwrap(); - let (workspace, screen) = state.screen_of(right_pane).unwrap(); - ( - crate::ResourceSelectors { - machine: Some(registry.machine_id().to_string()), - session: Some(registry.session_id().to_string()), - workspace: Some(state.workspaces[workspace].public_id.to_string()), - screen: Some(state.workspaces[workspace].screens[screen].public_id.to_string()), - ..crate::ResourceSelectors::default() - }, - ( - state.workspaces[workspace].screens[screen].layout_revision, - format!("{:?}", state.workspaces[workspace].screens[screen].layout_snapshot()), - format!("{:?}", state.workspaces[workspace].screens[screen].layout_undo), - ), - ) - }; - let fields = serde_json::json!({"confirm_close":false}).as_object().unwrap().clone(); - let fingerprint = serde_json::json!({ - "operation":"screen.layout.undo", - "selectors":selectors, - "fields":fields, - }); - let mutation = WorkspaceMutation::new("read-only-undo-preview", "test").unwrap(); - let before_registry = - mux.workspace_registry.lock().unwrap().resource_topology_snapshot().unwrap(); + mux.set_shutdown_owner_capacity_for_test(1); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let owned = mux.surface(surface.id).unwrap(); + let identity = mux.resource_terminal_host_identity(&owned).unwrap(); + owned.set_server_shutdown_failure_for_test(true); - let error = mux - .resource_topology_operation( - ResourceOperation::ScreenLayoutUndo, - selectors.clone(), - fields, - Some(before_registry.revision), - &mutation, - ) - .unwrap_err(); + mux.close_terminal(&identity.terminal_id, &identity.incarnation).unwrap(); + assert_eq!(mux.shutdown_owners.len(), 1); - let preview = error.downcast_ref::().unwrap(); - assert_eq!(preview.code, "confirmation.required"); - assert_eq!(preview.details["revision"], before_registry.revision.to_string()); - let confirmation_token = - preview.details["confirmation_token"].as_str().unwrap().to_string(); - assert_eq!(confirmation_token.len(), 64); - assert!(confirmation_token.bytes().all(|byte| byte.is_ascii_hexdigit())); - mux.with_state(|state| { - let (workspace, screen) = state.screen_of(right_pane).unwrap(); - let screen = &state.workspaces[workspace].screens[screen]; - assert_eq!(screen.layout_revision, before_screen.0); - assert_eq!(format!("{:?}", screen.layout_snapshot()), before_screen.1); - assert_eq!(format!("{:?}", screen.layout_undo), before_screen.2); - }); - let registry = mux.workspace_registry.lock().unwrap(); - assert_eq!(registry.resource_topology_snapshot().unwrap(), before_registry); - assert!( - registry.resource_events_after(before_registry.revision).unwrap().batches.is_empty() - ); - assert!( - registry - .lookup_resource_effect(&mutation.id, "screen.layout.undo", &fingerprint,) - .unwrap() - .is_none() - ); - drop(registry); + let error = mux.new_workspace(None, Some((80, 24))).unwrap_err(); + assert_eq!(error.to_string(), "surface_owner_capacity_exhausted"); + } - let missing_token_fields = - serde_json::json!({"confirm_close":true}).as_object().unwrap().clone(); - let missing_token_fingerprint = serde_json::json!({ - "operation":"screen.layout.undo", - "selectors":selectors, - "fields":missing_token_fields, - }); - let missing_token_mutation = - WorkspaceMutation::new("missing-token-undo-confirm", "test").unwrap(); - let missing_token = mux - .resource_topology_operation( - ResourceOperation::ScreenLayoutUndo, - selectors.clone(), - missing_token_fields, - Some(before_registry.revision), - &missing_token_mutation, - ) - .unwrap_err(); - let missing_token = missing_token.downcast_ref::().unwrap(); - assert_eq!(missing_token.code, "confirmation.required"); - assert_eq!(missing_token.details, preview.details); + #[test] + fn permanent_terminal_shutdown_owner_failure_stops_automatic_retry_sweeps() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let owned = mux.surface(surface.id).unwrap(); + let identity = mux.resource_terminal_host_identity(&owned).unwrap(); + let (_failing, attempts) = owned.set_recovering_server_shutdown_for_test(); - let missing_revision_fields = serde_json::json!({ - "confirm_close":true, - "confirmation_token":confirmation_token, - }) - .as_object() - .unwrap() - .clone(); - let missing_revision_fingerprint = serde_json::json!({ - "operation":"screen.layout.undo", - "selectors":selectors, - "fields":missing_revision_fields, - }); - let missing_revision_mutation = - WorkspaceMutation::new("missing-revision-undo-confirm", "test").unwrap(); - let missing_revision = mux - .resource_topology_operation( - ResourceOperation::ScreenLayoutUndo, - selectors.clone(), - missing_revision_fields, - None, - &missing_revision_mutation, - ) - .unwrap_err(); - let missing_revision = missing_revision.downcast_ref::().unwrap(); - assert_eq!(missing_revision.code, "confirmation.required"); - assert_eq!(missing_revision.details, preview.details); - let registry = mux.workspace_registry.lock().unwrap(); - assert_eq!(registry.resource_topology_snapshot().unwrap(), before_registry); - assert!( - registry.resource_events_after(before_registry.revision).unwrap().batches.is_empty() - ); + mux.close_terminal(&identity.terminal_id, &identity.incarnation).unwrap(); assert!( - registry - .lookup_resource_effect( - &missing_token_mutation.id, - "screen.layout.undo", - &missing_token_fingerprint, - ) - .unwrap() - .is_none() + mux.shutdown_owner_reconciler + .wait_until_degraded(Instant::now() + Duration::from_secs(1)), + "reconciler did not exhaust its retry budget" ); - assert!( - registry - .lookup_resource_effect( - &missing_revision_mutation.id, - "screen.layout.undo", - &missing_revision_fingerprint, - ) - .unwrap() - .is_none() + let settled = attempts.load(); + assert!(settled >= 4, "reconciler did not exercise its retry budget"); + + assert_eq!( + attempts.load(), + settled, + "permanent cleanup failure kept an unbounded retry sweep alive" ); - drop(registry); + assert_eq!(mux.shutdown_owners.len(), 1); + assert_eq!( + mux.shutdown_cleanup_health(), + ShutdownCleanupHealth { pending: 1, retrying: false, degraded: true } + ); + } - let late_tab = mux.new_tab(Some(right_pane), None, Some((38, 22))).unwrap(); - let stale_fields = serde_json::json!({ - "confirm_close":true, - "confirmation_token":confirmation_token, - }) - .as_object() - .unwrap() - .clone(); - let stale_fingerprint = serde_json::json!({ - "operation":"screen.layout.undo", - "selectors":selectors, - "fields":stale_fields, + #[test] + fn cleanup_scheduled_during_the_final_retry_gets_a_fresh_retry_budget() { + let mux = test_mux(); + let options = mux.surface_options.lock().unwrap().clone(); + let first = + Surface::spawn_for_test(mux.next_id(), options.clone(), Arc::downgrade(&mux)).unwrap(); + let (first_failing, _) = first.set_recovering_server_shutdown_for_test(); + let second = Surface::spawn_for_test(mux.next_id(), options, Arc::downgrade(&mux)).unwrap(); + let (second_failing, _) = second.set_recovering_server_shutdown_for_test(); + let injected = Arc::new(AtomicBool::new(false)); + let (injected_tx, injected_rx) = std::sync::mpsc::sync_channel(1); + *mux.shutdown_owner_reconciler.after_attempt.lock().unwrap() = Some(Arc::new({ + let mux = mux.clone(); + let injected = injected.clone(); + move |attempt| { + if injected.load(Ordering::Acquire) { + return; + } + if attempt < SHUTDOWN_RECONCILE_MAX_ATTEMPTS { + // Publish real pending work to bypass only the retry + // backoff. The worker consumes this signal before its next + // attempt, so hosted load cannot delay the final hook. + mux.shutdown_owner_reconciler.schedule(); + return; + } + if attempt != SHUTDOWN_RECONCILE_MAX_ATTEMPTS + || injected.swap(true, Ordering::AcqRel) + { + return; + } + mux.retire_surface_runtime(second.clone()); + first_failing.store(false, Ordering::Release); + second_failing.store(false, Ordering::Release); + let _ = injected_tx.send(()); + } + })); + + let deadline = Instant::now() + Duration::from_secs(2); + mux.retire_surface_runtime(first); + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("final cleanup attempt exceeded its failure deadline"); + injected_rx + .recv_timeout(remaining) + .expect("final cleanup attempt did not stage the second owner"); + let completed = mux.shutdown_owner_reconciler.wait_until(deadline, || { + injected.load(Ordering::Acquire) && mux.shutdown_owners.is_empty() }); - let stale_mutation = WorkspaceMutation::new("stale-undo-confirm", "test").unwrap(); - let stale = mux - .resource_topology_operation( - ResourceOperation::ScreenLayoutUndo, - selectors.clone(), - stale_fields.clone(), - Some(before_registry.revision), - &stale_mutation, - ) - .unwrap_err(); - let refreshed = stale - .downcast_ref::() - .unwrap_or_else(|| panic!("stale confirmation returned an untyped error: {stale:#}")); - assert_eq!(refreshed.code, "confirmation.required"); - assert_ne!(refreshed.details["confirmation_token"], stale_fields["confirmation_token"]); - assert!(mux.surface(right.id).is_some()); - assert!(mux.surface(late_tab.id).is_some()); + let reconciled = + completed && injected.load(Ordering::Acquire) && mux.shutdown_owners.is_empty(); + if !reconciled { + mux.close_all_surfaces_for_shutdown().unwrap(); + } + assert!( - mux.workspace_registry - .lock() - .unwrap() - .lookup_resource_effect( - &stale_mutation.id, - "screen.layout.undo", - &stale_fingerprint, - ) - .unwrap() - .is_none() + reconciled, + "cleanup staged during the final attempt was left without a retry worker" ); + } - let refreshed_revision = - refreshed.details["revision"].as_str().unwrap().parse::().unwrap(); - let confirmed_fields = serde_json::json!({ - "confirm_close":true, - "confirmation_token":refreshed.details["confirmation_token"], - }) - .as_object() - .unwrap() - .clone(); - let committed = mux - .resource_topology_operation( - ResourceOperation::ScreenLayoutUndo, - selectors, - confirmed_fields, - Some(refreshed_revision), - &WorkspaceMutation::new("fresh-undo-confirm", "test").unwrap(), - ) - .unwrap(); - assert!(!committed.replayed); - assert!(mux.surface(right.id).is_none()); - assert_terminal_view_detached(&mux, late_tab.id); + #[test] + fn rejected_sidebar_admission_does_not_launch_a_process() { + let mux = Mux::new("sidebar-admission", SurfaceOptions::default()); + mux.set_shutdown_owner_capacity_for_test(0); + let options = + SidebarPluginOptions { command: vec!["/cmux-test-must-not-launch".into()], cwd: None }; + + let error = mux.spawn_sidebar_plugin_surface(&options, (80, 24)).unwrap_err(); + drop(mux); + + assert_eq!(error.to_string(), "surface_owner_capacity_exhausted"); } #[test] - fn layout_undo_confirmation_fences_exact_created_pane_tab_membership() { + fn exited_sidebar_surface_transfers_its_shutdown_owner_before_removal() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + mux.configure_sidebar_plugin(Some(SidebarPluginOptions { + command: vec!["sidebar-test".into()], + cwd: None, + })); + let status = mux.ensure_sidebar_plugin(80, 24, true); + let surface_id = status.surface.expect("sidebar surface was not created"); + let surface = mux.surface(surface_id).unwrap(); + surface.set_server_shutdown_failure_for_test(true); - let LayoutUndoResult::ConfirmationRequired { revision, .. } = - mux.undo_layout(right_pane, None, false).unwrap() - else { - panic!("pane creation undo must require confirmation"); - }; - let late_tab = mux.new_tab(Some(right_pane), None, Some((38, 22))).unwrap(); + mux.surface_exited(surface_id); - let error = mux.undo_layout(right_pane, Some(revision), true).unwrap_err(); - assert!(matches!( - error.downcast_ref::(), - Some(LayoutUndoError::Stale(message)) - if message.contains("layout revision conflict") + assert!(mux.surface(surface_id).is_none()); + assert_eq!( + mux.shutdown_owners.len(), + 1, + "sidebar exit discarded its still-retryable process owner" + ); + + surface.set_server_shutdown_failure_for_test(false); + let _ = mux.terminate_staged_shutdown_owners_until(Instant::now() + Duration::from_secs(1)); + assert!(mux.shutdown_owners.is_empty()); + } + + #[cfg(unix)] + #[test] + fn rejected_terminal_admission_does_not_launch_a_process() { + let mux = Mux::new( + "terminal-admission", + SurfaceOptions { + command: Some(vec!["/cmux-test-must-not-launch".into()]), + ..SurfaceOptions::default() + }, + ); + mux.set_shutdown_owner_capacity_for_test(0); + + let state = mux.state.lock().unwrap(); + let spawn = std::thread::spawn({ + let mux = mux.clone(); + move || mux.spawn_surface_with(None, None, Some((80, 24)), None, None) + }); + drop(state); + let error = spawn.join().unwrap().unwrap_err(); + drop(mux); + + assert_eq!(error.to_string(), "surface_owner_capacity_exhausted"); + } + + #[cfg(unix)] + #[test] + fn cold_start_terminal_adoption_never_handshakes_inline() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let root = std::env::temp_dir().join(format!( + "cmux-adoption-startup-budget-{}", + crate::workspace_registry::new_uuid_v4() )); - assert!(mux.surface(right.id).is_some()); - assert!(mux.surface(late_tab.id).is_some()); + std::fs::create_dir_all(&root).unwrap(); + let uid = std::fs::metadata(&root).unwrap().uid(); + let mux = Mux::new_for_test("adoption-startup-budget", SurfaceOptions::default()); + let workspace = + mux.create_empty_workspace(Some("startup-budget".into()), None, None).unwrap(); + { + let mut registry = mux.workspace_registry.lock().unwrap(); + for index in 0..3 { + let terminal_id = TerminalId::random().unwrap().to_hex(); + let incarnation = crate::terminal_host::HostIncarnation::random().unwrap().to_hex(); + commit_terminal_transition( + &mut registry, + "terminal-reserved", + &format!("startup-adoption-reserve-{index}"), + &RegistryTerminal { + terminal_id: terminal_id.clone(), + workspace_key: workspace.key.clone(), + incarnation: None, + lifecycle: TerminalLifecycle::Launching, + launch_spec: serde_json::json!({}), + exit: None, + }, + ) + .unwrap(); + let record = crate::terminal_host_runtime::TerminalHostRecord { + record_version: 1, + terminal_id: terminal_id.clone(), + incarnation, + endpoint: format!("/tmp/cmux-th-{uid}/{terminal_id}.sock"), + owner_token: "01".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: workspace.key.clone(), + supports_set_defaults: false, + supports_terminate_only: false, + supports_terminate_ack: false, + supports_clear_history: false, + }; + let path = root.join(format!("{terminal_id}.json")); + std::fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); + let mut permissions = std::fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions).unwrap(); + } + } + mux.update_surface_options(|surface_options| { + surface_options.terminal_host_root = Some(root.clone()); + }); + mux.terminal_adoption_coordinator.state.lock().unwrap().stopping = true; - let LayoutUndoResult::ConfirmationRequired { revision: refreshed, .. } = - mux.undo_layout(right_pane, None, false).unwrap() - else { - panic!("a fresh preview must capture the new tab membership"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let release_rx = Arc::new(Mutex::new(release_rx)); + *mux.terminal_adoption_surface_factory.lock().unwrap() = Some(Arc::new({ + move |_, _| { + started_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + anyhow::bail!("blocked startup handshake") + } + })); + let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1); + let adoption = std::thread::spawn({ + let mux = mux.clone(); + move || { + done_tx.send(mux.adopt_terminal_hosts()).unwrap(); + } + }); + + let started_inline = started_rx.recv_timeout(Duration::from_millis(100)).is_ok(); + let completed_inline = done_rx.recv_timeout(Duration::from_millis(100)).ok(); + let completed_without_release = completed_inline.is_some(); + for _ in 0..3 { + release_tx.send(()).unwrap(); + } + let result = match completed_inline { + Some(result) => result, + None => done_rx.recv_timeout(Duration::from_secs(2)).unwrap(), }; - assert!(refreshed > revision); - assert!(matches!( - mux.undo_layout(right_pane, Some(refreshed), true).unwrap(), - LayoutUndoResult::Undone { .. } - )); - assert_terminal_view_detached(&mux, right.id); - assert_terminal_view_detached(&mux, late_tab.id); + adoption.join().unwrap(); + mux.terminal_adoption_coordinator.state.lock().unwrap().stopping = false; + mux.request_daemon_shutdown(); + let _ = std::fs::remove_dir_all(root); + + result.unwrap(); + assert!(!started_inline, "cold-start adoption entered a persisted host handshake inline"); + assert!( + completed_without_release, + "cold-start adoption waited for a persisted host handshake" + ); } + #[cfg(unix)] #[test] - fn layout_undo_confirmation_and_tab_creation_commit_atomically() { - for _ in 0..16 { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let LayoutUndoResult::ConfirmationRequired { revision, .. } = - mux.undo_layout(right_pane, None, false).unwrap() - else { - panic!("pane creation undo must require confirmation"); - }; - let start = Arc::new(std::sync::Barrier::new(3)); - let tab = { - let mux = mux.clone(); - let start = start.clone(); - std::thread::spawn(move || { - start.wait(); - mux.new_tab(Some(right_pane), None, Some((38, 22))) - }) - }; - let undo = { - let mux = mux.clone(); - let start = start.clone(); - std::thread::spawn(move || { - start.wait(); - mux.undo_layout(right_pane, Some(revision), true) - }) - }; - start.wait(); - let tab = tab.join().unwrap(); - let undo = undo.join().unwrap(); + fn terminal_adoption_overflow_uses_one_bounded_worker_pool() { + let options = SurfaceOptions::default(); + let mux = Mux::new_for_test("adoption-worker-bound", options.clone()); + let root = + std::env::temp_dir().join(format!("cmux-adoption-worker-bound-{}", std::process::id())); - match (tab, undo) { - (Ok(tab), Err(error)) => { - assert!(matches!( - error.downcast_ref::(), - Some(LayoutUndoError::Stale(message)) - if message.contains("layout revision conflict") - )); - assert!(mux.surface(right.id).is_some()); - assert!(mux.surface(tab.id).is_some()); - } - (Err(_), Ok(LayoutUndoResult::Undone { .. })) => { - assert_terminal_view_detached(&mux, right.id); - } - (tab, undo) => { - panic!("tab creation and confirmed undo partially committed: {tab:?}, {undo:?}") - } + for _ in 0..3 { + let terminal_id = TerminalId::random().unwrap().to_hex(); + let record_path = root.join(format!("{terminal_id}.json")); + mux.schedule_terminal_adoption( + options.clone(), + crate::terminal_host_runtime::TerminalHostRecord { + record_version: 2, + terminal_id, + incarnation: crate::terminal_host::HostIncarnation::random().unwrap().to_hex(), + endpoint: record_path.with_extension("sock").to_string_lossy().into_owned(), + owner_token: "00".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: String::new(), + supports_set_defaults: true, + supports_terminate_only: true, + supports_terminate_ack: false, + supports_clear_history: true, + }, + record_path, + ); + } + + let workers_running = + mux.terminal_adoption_coordinator.state.lock().unwrap().workers_running; + mux.request_daemon_shutdown(); + let _ = std::fs::remove_dir_all(root); + + assert_eq!( + workers_running, TERMINAL_ADOPTION_WORKERS, + "adoption did not preflight its fixed worker pool" + ); + assert!( + workers_running <= TERMINAL_ADOPTION_WORKERS, + "overflow adoption retained {workers_running} concurrent retry threads" + ); + } + + #[cfg(unix)] + #[test] + fn terminal_adoption_overflow_uses_a_bounded_deferred_tier() { + fn task(index: usize) -> TerminalAdoptionTask { + let terminal_id = format!("{index:032x}"); + TerminalAdoptionTask { + options: SurfaceOptions::default(), + record: crate::terminal_host_runtime::TerminalHostRecord { + record_version: 1, + terminal_id: terminal_id.clone(), + incarnation: terminal_id, + endpoint: String::new(), + owner_token: String::new(), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: String::new(), + supports_set_defaults: false, + supports_terminate_only: false, + supports_terminate_ack: false, + supports_clear_history: false, + }, + record_path: std::path::PathBuf::new(), + next_attempt: Instant::now(), + delay: Duration::from_millis(100), } } + + let mut state = TerminalAdoptionQueueState::default(); + for index in 0..TERMINAL_ADOPTION_QUEUE_CAPACITY { + assert!(state.enqueue(task(index))); + } + for index in TERMINAL_ADOPTION_QUEUE_CAPACITY..TERMINAL_ADOPTION_QUEUE_CAPACITY * 2 { + assert!(state.enqueue(task(index))); + } + assert!( + !state.enqueue(task(TERMINAL_ADOPTION_QUEUE_CAPACITY * 2)), + "adoption overflow exceeded both bounded queue tiers" + ); + state.rescan_required = true; + state.next_rescan = Some(Instant::now()); + assert!( + !state.rescan_due(Instant::now()), + "adoption overflow rescanned while deferred records remained" + ); + + let retry = state.tasks.pop().unwrap(); + let retry_id = retry.record.terminal_id.clone(); + state.in_flight += 1; + state.in_flight -= 1; + state.requeue_retry(retry); + assert!( + state.tasks.iter().any(|task| { + task.record.terminal_id == format!("{TERMINAL_ADOPTION_QUEUE_CAPACITY:032x}") + }), + "a permanently retrying active record starved the oldest deferred record" + ); + assert_eq!( + state.deferred.back().map(|task| task.record.terminal_id.as_str()), + Some(retry_id.as_str()), + "a retry did not rotate behind deferred adoption work" + ); + + for _ in 0..TERMINAL_ADOPTION_DEFERRED_CAPACITY { + state.tasks.pop().unwrap(); + state.promote_deferred(); + } + assert_eq!(state.active_len(), TERMINAL_ADOPTION_QUEUE_CAPACITY); + assert!(state.deferred.is_empty()); + assert!( + state.rescan_due(Instant::now()), + "adoption overflow did not request one new batch after draining deferred records" + ); } + #[cfg(unix)] #[test] - fn layout_undo_public_edge_failures_are_typed() { - let mux = test_mux(); - let unknown_pane = mux.undo_layout(u64::MAX, None, false).unwrap_err(); + fn terminal_adoption_rescan_uses_one_registry_snapshot() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let LayoutUndoResult::ConfirmationRequired { .. } = - mux.undo_layout(right_pane, None, false).unwrap() - else { - panic!("pane creation undo must require confirmation"); - }; - let missing_revision = mux.undo_layout(right_pane, None, true).unwrap_err(); + let root = std::env::temp_dir() + .join(format!("cmux-adoption-snapshot-{}", crate::workspace_registry::new_uuid_v4())); + std::fs::create_dir_all(&root).unwrap(); + let uid = std::fs::metadata(&root).unwrap().uid(); + for _ in 0..3 { + let terminal_id = TerminalId::random().unwrap().to_hex(); + let record = crate::terminal_host_runtime::TerminalHostRecord { + record_version: 1, + terminal_id: terminal_id.clone(), + incarnation: crate::terminal_host::HostIncarnation::random().unwrap().to_hex(), + endpoint: format!("/tmp/cmux-th-{uid}/{terminal_id}.sock"), + owner_token: "01".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: String::new(), + supports_set_defaults: false, + supports_terminate_only: false, + supports_terminate_ack: false, + supports_clear_history: false, + }; + let path = root.join(format!("{terminal_id}.json")); + std::fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); + let mut permissions = std::fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions).unwrap(); + } + let mux = Mux::new_for_test("adoption-snapshot", SurfaceOptions::default()); + mux.update_surface_options(|options| options.terminal_host_root = Some(root.clone())); + mux.shutting_down.store(true, Ordering::Release); + mux.workspace_registry.lock().unwrap().reset_terminal_snapshot_count_for_test(); + + assert!(mux.rescan_terminal_adoptions()); + let snapshots = mux.workspace_registry.lock().unwrap().terminal_snapshot_count_for_test(); + let _ = std::fs::remove_dir_all(root); assert_eq!( - [ - matches!( - unknown_pane.downcast_ref::(), - Some(LayoutUndoError::Stale(_)) - ), - matches!( - missing_revision.downcast_ref::(), - Some(LayoutUndoError::Stale(_)) - ), - ], - [true, true], - "public layout-undo edge failures must preserve their typed error" + snapshots, 1, + "one adoption rescan queried the full terminal registry {snapshots} times" ); } + #[cfg(unix)] #[test] - fn layout_undo_coalesces_resize_and_fences_pane_closure() { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + fn exited_placeholder_does_not_suppress_terminal_adoption_rescan() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; - assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.6, 7, 11)); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert!( - screen - .layout_snapshot_for_coalescing_change(Some(LayoutMutationKey::Resize { - owner: LayoutResizeOwner::ControlClient(7), - transaction: 11, - })) - .is_none(), - "continuation samples must reuse the transaction's first snapshot" - ); - }); - assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 7, 11)); - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } + let root = std::env::temp_dir().join(format!( + "cmux-adoption-exited-placeholder-{}", + crate::workspace_registry::new_uuid_v4() )); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.layout_columns[1].width, 0.5); - }); + std::fs::create_dir_all(&root).unwrap(); + let mux = Mux::new_for_test("adoption-exited-placeholder", SurfaceOptions::default()); + let workspace = + mux.create_empty_workspace(Some("adoption-placeholder".into()), None, None).unwrap(); + let terminal_id = TerminalId::random().unwrap().to_hex(); + let incarnation = crate::terminal_host::HostIncarnation::random().unwrap().to_hex(); + let placeholder = insert_running_terminal_identity_surface( + &mux, + &terminal_id, + &incarnation, + &workspace.key, + ); + assert!(placeholder.is_dead()); - let LayoutUndoResult::ConfirmationRequired { revision, closes_panes, .. } = - mux.undo_layout(right_pane, None, false).unwrap() - else { - panic!("pane creation undo must require confirmation"); + let record = crate::terminal_host_runtime::TerminalHostRecord { + record_version: 2, + terminal_id: terminal_id.clone(), + incarnation, + endpoint: format!( + "/tmp/cmux-th-{}/{}.sock", + std::fs::metadata(&root).unwrap().uid(), + terminal_id + ), + owner_token: "01".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 1, + host_start_nonce: "01".repeat(32), + workspace_key: workspace.key, + supports_set_defaults: true, + supports_terminate_only: true, + supports_terminate_ack: false, + supports_clear_history: true, }; - assert_eq!(closes_panes, vec![right_pane]); - assert!( - mux.undo_layout(right_pane, None, true) - .unwrap_err() - .to_string() - .contains("requires the preview revision") - ); + let record_path = root.join(format!("{terminal_id}.json")); + std::fs::write(&record_path, serde_json::to_vec(&record).unwrap()).unwrap(); + let mut permissions = std::fs::metadata(&record_path).unwrap().permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(&record_path, permissions).unwrap(); + mux.update_surface_options(|options| options.terminal_host_root = Some(root.clone())); + mux.terminal_adoption_coordinator.state.lock().unwrap().stopping = true; + + assert!(mux.rescan_terminal_adoptions()); + let rescan_required = + mux.terminal_adoption_coordinator.state.lock().unwrap().rescan_required; + mux.terminal_adoption_coordinator.state.lock().unwrap().stopping = false; + let _ = std::fs::remove_dir_all(root); + assert!( - mux.undo_layout(right_pane, Some(revision.saturating_sub(1)), true) - .unwrap_err() - .to_string() - .contains("revision conflict") + rescan_required, + "an exited identity placeholder suppressed the retry for its live discovery record" ); - assert!(mux.surface(right.id).is_some()); - - assert!(matches!( - mux.undo_layout(right_pane, Some(revision), true).unwrap(), - LayoutUndoResult::Undone { .. } - )); - assert_terminal_view_detached(&mux, right.id); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert!(!screen.layout_columns_active()); - assert!(screen.viewport_splits.is_empty()); - assert_eq!(screen.root.pane_ids_vec(), vec![first_pane]); - assert_eq!(screen.active_pane, first_pane); - assert!(screen.layout_column_projection_is_consistent()); - }); } + #[cfg(unix)] #[test] - fn layout_undo_preserves_focus_when_the_current_pane_survives() { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + fn terminal_adoption_retries_when_the_registry_read_is_uncertain() { + use std::os::unix::fs::MetadataExt; - assert!(mux.focus_pane(first_pane)); - assert!(mux.set_viewport_pane_width(right_pane, 0.7)); - assert!(mux.focus_pane(right_pane)); - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } + let root = std::env::temp_dir().join(format!( + "cmux-adoption-registry-error-{}", + crate::workspace_registry::new_uuid_v4() )); + std::fs::create_dir_all(&root).unwrap(); + let terminal_id = TerminalId::random().unwrap().to_hex(); + let record_path = root.join(format!("{terminal_id}.json")); + let record = crate::terminal_host_runtime::TerminalHostRecord { + record_version: 1, + terminal_id: terminal_id.clone(), + incarnation: crate::terminal_host::HostIncarnation::random().unwrap().to_hex(), + endpoint: format!( + "/tmp/cmux-th-{}/{}.sock", + std::fs::metadata(&root).unwrap().uid(), + terminal_id + ), + owner_token: "01".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: String::new(), + supports_set_defaults: false, + supports_terminate_only: false, + supports_terminate_ack: false, + supports_clear_history: false, + }; + let task = TerminalAdoptionTask { + options: SurfaceOptions::default(), + record, + record_path, + next_attempt: Instant::now(), + delay: Duration::from_millis(100), + }; + let mux = Mux::new_for_test("adoption-registry-error", SurfaceOptions::default()); + mux.workspace_registry.lock().unwrap().set_terminal_record_read_failure_for_test(true); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.active_pane, right_pane); - assert_eq!(screen.layout_columns[1].width, 0.5); - }); + let complete = mux.try_terminal_adoption(&task); + + mux.workspace_registry.lock().unwrap().set_terminal_record_read_failure_for_test(false); + let _ = std::fs::remove_dir_all(root); + assert!( + !complete, + "an uncertain registry read was treated as proof that the host should be cleaned up" + ); } + #[cfg(unix)] #[test] - fn layout_undo_restores_focus_to_the_restored_zoomed_pane() { + fn terminal_adoption_retries_when_the_running_transition_write_fails() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let workspace = + mux.create_empty_workspace(Some("adoption-write-retry".into()), None, None).unwrap(); + let terminal_id = TerminalId::random().unwrap().to_hex(); + let incarnation = crate::terminal_host::HostIncarnation::random().unwrap().to_hex(); + { + let mut registry = mux.workspace_registry.lock().unwrap(); + commit_terminal_transition( + &mut registry, + "terminal-reserved", + "adoption-write-retry-reserve", + &RegistryTerminal { + terminal_id: terminal_id.clone(), + workspace_key: workspace.key, + incarnation: None, + lifecycle: TerminalLifecycle::Launching, + launch_spec: serde_json::json!({}), + exit: None, + }, + ) + .unwrap(); + commit_terminal_lifecycle( + &mut registry, + "terminal-ready", + "adoption-write-retry-running", + &terminal_id, + TerminalLifecycle::Running, + Some(&incarnation), + None, + ) + .unwrap(); + } + let task = TerminalAdoptionTask { + options: SurfaceOptions::default(), + record: crate::terminal_host_runtime::TerminalHostRecord { + record_version: 2, + terminal_id: terminal_id.clone(), + incarnation, + endpoint: String::new(), + owner_token: "01".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 1, + host_start_nonce: "01".repeat(32), + workspace_key: String::new(), + supports_set_defaults: true, + supports_terminate_only: true, + supports_terminate_ack: false, + supports_clear_history: true, + }, + record_path: std::path::PathBuf::new(), + next_attempt: Instant::now(), + delay: Duration::from_millis(100), + }; + mux.set_terminal_close_failure_for_test(true).unwrap(); - assert!(mux.focus_pane(first_pane)); - mux.zoom_pane(Some(first_pane), ZoomMode::On).unwrap(); - mux.zoom_pane(Some(first_pane), ZoomMode::Off).unwrap(); - assert!(mux.focus_pane(right_pane)); - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } + let complete = mux.try_terminal_adoption(&task); + + mux.set_terminal_close_failure_for_test(false).unwrap(); + let terminal = + mux.workspace_registry.lock().unwrap().terminal_record(&terminal_id).unwrap().unwrap(); + assert_eq!(terminal.lifecycle, TerminalLifecycle::Running); + assert!( + !complete, + "a failed Running-to-Adopting write permanently completed the adoption task" + ); + } + + #[cfg(unix)] + #[test] + fn blocked_terminal_adoption_does_not_stall_an_unrelated_terminal() { + let options = SurfaceOptions::default(); + let mux = Mux::new_for_test("adoption-parallel-progress", options.clone()); + let workspace = + mux.create_empty_workspace(Some("parallel-progress".into()), None, None).unwrap(); + let root = std::env::temp_dir().join(format!( + "cmux-adoption-parallel-progress-{}", + crate::workspace_registry::new_uuid_v4() )); + let mut records = Vec::new(); + { + let mut registry = mux.workspace_registry.lock().unwrap(); + for index in 0..2 { + let terminal_id = TerminalId::random().unwrap().to_hex(); + let incarnation = crate::terminal_host::HostIncarnation::random().unwrap().to_hex(); + let record_path = root.join(format!("{terminal_id}.json")); + commit_terminal_transition( + &mut registry, + "terminal-reserved", + &format!("parallel-adoption-reserve-{index}"), + &RegistryTerminal { + terminal_id: terminal_id.clone(), + workspace_key: workspace.key.clone(), + incarnation: None, + lifecycle: TerminalLifecycle::Launching, + launch_spec: serde_json::json!({}), + exit: None, + }, + ) + .unwrap(); + commit_terminal_lifecycle( + &mut registry, + "terminal-adopting", + &format!("parallel-adoption-start-{index}"), + &terminal_id, + TerminalLifecycle::Adopting, + Some(&incarnation), + None, + ) + .unwrap(); + records.push(( + crate::terminal_host_runtime::TerminalHostRecord { + record_version: 2, + terminal_id, + incarnation, + endpoint: record_path.with_extension("sock").to_string_lossy().into_owned(), + owner_token: "00".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: workspace.key.clone(), + supports_set_defaults: true, + supports_terminate_only: true, + supports_terminate_ack: false, + supports_clear_history: true, + }, + record_path, + )); + } + } - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.zoomed_pane, Some(first_pane)); - assert_eq!(screen.active_pane, first_pane); - assert_eq!(state.active_pane(), Some(first_pane)); - }); + let calls = Arc::new(AtomicUsize::new(0)); + let (first_started_tx, first_started_rx) = std::sync::mpsc::sync_channel(1); + let (second_started_tx, second_started_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let release_rx = Arc::new(Mutex::new(release_rx)); + *mux.terminal_adoption_surface_factory.lock().unwrap() = Some(Arc::new({ + let mux = Arc::downgrade(&mux); + let options = options.clone(); + move |id, identity| { + let call = calls.fetch_add(1, Ordering::AcqRel); + if call == 0 { + first_started_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + } else if call == 1 { + let _ = second_started_tx.try_send(()); + } + Surface::spawn_for_test_with_terminal_host_identity( + id, + options.clone(), + mux.clone(), + identity, + ) + } + })); + + let (first_record, first_path) = records.remove(0); + mux.schedule_terminal_adoption(options.clone(), first_record, first_path); + first_started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let (second_record, second_path) = records.remove(0); + mux.schedule_terminal_adoption(options, second_record, second_path); + let second_progressed = second_started_rx.recv_timeout(Duration::from_millis(300)).is_ok(); + release_tx.send(()).unwrap(); + + assert!( + mux.terminal_adoption_coordinator + .wait_until_idle(&mux.terminal_adoptions, Instant::now() + Duration::from_secs(2),), + "terminal adoptions did not finish" + ); + mux.shutdown().unwrap(); + let _ = std::fs::remove_dir_all(root); + + assert!( + second_progressed, + "one blocked terminal adoption stalled an unrelated queued terminal" + ); } + #[cfg(unix)] #[test] - fn layout_undo_preserves_inactive_stack_selection() { - let mux = test_mux(); - let applied = mux - .apply_layout( - None, - None, - &split_spec( - SplitDir::Right, - 0.5, - LayoutSpec::Stack { pane_count: 2, expanded_index: 0 }, - LayoutSpec::Stack { pane_count: 2, expanded_index: 0 }, - ), - Some((80, 22)), + fn daemon_handoff_does_not_terminate_an_in_flight_terminal_adoption() { + let options = SurfaceOptions::default(); + let mux = Mux::new_for_test("adoption-handoff", options.clone()); + let workspace = mux.create_empty_workspace(Some("survivor".into()), None, None).unwrap(); + let terminal_id = TerminalId::random().unwrap(); + let terminal_hex = terminal_id.to_hex(); + let incarnation = crate::terminal_host::HostIncarnation::random().unwrap().to_hex(); + let record_path = std::env::temp_dir().join(format!("{terminal_hex}.json")); + let record = crate::terminal_host_runtime::TerminalHostRecord { + record_version: 2, + terminal_id: terminal_hex.clone(), + incarnation: incarnation.clone(), + endpoint: record_path.with_extension("sock").to_string_lossy().into_owned(), + owner_token: "00".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: workspace.key.clone(), + supports_set_defaults: true, + supports_terminate_only: true, + supports_terminate_ack: false, + supports_clear_history: true, + }; + { + let mut registry = mux.workspace_registry.lock().unwrap(); + commit_terminal_transition( + &mut registry, + "terminal-reserved", + "adoption-handoff-reserve", + &RegistryTerminal { + terminal_id: terminal_hex.clone(), + workspace_key: workspace.key, + incarnation: None, + lifecycle: TerminalLifecycle::Launching, + launch_spec: serde_json::json!({}), + exit: None, + }, + ) + .unwrap(); + commit_terminal_lifecycle( + &mut registry, + "terminal-adopting", + "adoption-handoff-adopting", + &terminal_hex, + TerminalLifecycle::Adopting, + Some(&incarnation), + None, ) .unwrap(); - let [left_first, left_second, right_first, _right_second] = - applied.panes.iter().map(|pane| pane.pane).collect::>()[..] - else { - panic!("two two-pane stacks should create four panes"); - }; - { - let mut state = mux.state.lock().unwrap(); - let screen = &mut state.workspaces[0].screens[0]; - let root = std::mem::replace(&mut screen.root, Node::Leaf(0)); - let Node::Split { id, a, b, .. } = root else { - panic!("test layout should have two stack branches"); - }; - screen.layout_columns = vec![ - LayoutColumn { id: mux.next_id(), width: 1.0, root: *a, zellij_auto_layout: None }, - LayoutColumn { id, width: 0.5, root: *b, zellij_auto_layout: None }, - ]; - screen.sync_layout_column_projection(); - Mux::rebuild_split_screen_index(&mut state); } - mux.commit_ordinary_full_resource_projection( - "test.viewport_stack.prepare", - serde_json::json!({}), - ) - .unwrap(); + let kill_attempts = Arc::new(Mutex::new(None)); + *mux.terminal_adoption_surface_factory.lock().unwrap() = Some(Arc::new({ + let mux = Arc::downgrade(&mux); + let options = options.clone(); + let kill_attempts = kill_attempts.clone(); + move |id, identity| { + let surface = Surface::spawn_for_test_with_terminal_host_identity( + id, + options.clone(), + mux.clone(), + identity, + )?; + let (_, attempts) = surface.set_recovering_server_shutdown_for_test(); + *kill_attempts.lock().unwrap() = Some(attempts); + Ok(surface) + } + })); + let (attached_tx, attached_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let release_rx = Arc::new(Mutex::new(release_rx)); + *mux.terminal_adoption_after_attach.lock().unwrap() = Some(Arc::new({ + move || { + attached_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + } + })); - assert!(mux.focus_pane(right_first)); - assert!(mux.set_viewport_pane_width(right_first, 0.7)); - assert!(mux.focus_pane(left_second)); - assert!(mux.focus_pane(right_first)); - assert!(matches!( - mux.undo_layout(right_first, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); + mux.schedule_terminal_adoption(options, record, record_path); + attached_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + mux.request_daemon_shutdown(); + release_tx.send(()).unwrap(); + assert!( + mux.terminal_adoption_coordinator + .wait_until_idle(&mux.terminal_adoptions, Instant::now() + Duration::from_secs(2),), + "terminal adoption did not finish" + ); + let attempts = + kill_attempts.lock().unwrap().clone().expect("adoption surface was not created").load(); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.active_pane, right_first); - assert!(matches!( - &screen.layout_columns[0].root, - Node::Stack { expanded, .. } if *expanded == left_second - )); - assert!(!matches!( - &screen.layout_columns[0].root, - Node::Stack { expanded, .. } if *expanded == left_first - )); - assert!(matches!( - &screen.root, - Node::Split { a, .. } - if matches!( - a.as_ref(), - Node::Stack { expanded, .. } if *expanded == left_second - ) - )); - assert!(screen.layout_column_projection_is_consistent()); - }); + assert_eq!( + attempts, 0, + "daemon handoff terminated the surface attached to the durable host" + ); } #[test] - fn layout_undo_coalesces_every_target_in_one_resize_transaction() { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - let bottom = mux.split(right_pane, SplitDir::Down, Some((38, 10))).unwrap(); - let bottom_pane = mux.with_state(|state| state.pane_of(bottom.id).unwrap()); - let (split, initial_ratio, initial_undo_len) = mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - let Node::Split { id, ratio, .. } = &screen.layout_columns[1].root else { - panic!("right viewport column must contain the vertical split"); - }; - (*id, *ratio, screen.layout_undo.len()) - }); + fn ordinary_browser_close_terminates_the_owner_staged_during_removal() { + fn read_ws_json(ws: &mut tungstenite::WebSocket) -> Value { + loop { + match ws.read().unwrap() { + tungstenite::Message::Text(text) => { + return serde_json::from_str(&text).unwrap(); + } + tungstenite::Message::Binary(bytes) => { + return serde_json::from_slice(&bytes).unwrap(); + } + _ => {} + } + } + } - assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 9, 41)); - assert!(mux.set_split_ratio_in_transaction_checked(split, 0.7, 9, 41).is_ok()); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.layout_undo.len(), initial_undo_len + 1); - }); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (query_tx, query_rx) = std::sync::mpsc::sync_channel(1); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + ws.send(tungstenite::Message::Text( + serde_json::json!({"id": discover["id"], "result": {}}).to_string().into(), + )) + .unwrap(); - assert!(matches!( - mux.undo_layout(bottom_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.layout_columns[1].width, 0.5); - let Node::Split { ratio, .. } = &screen.layout_columns[1].root else { - panic!("right viewport column must retain the vertical split"); - }; - assert!((*ratio - initial_ratio).abs() < f32::EPSILON); - assert_eq!(screen.layout_undo.len(), initial_undo_len); + let query = read_ws_json(&mut ws); + assert_eq!(query["method"], "Target.getTargets"); + query_tx.send(()).unwrap(); + ws.send(tungstenite::Message::Text( + serde_json::json!({ + "id": query["id"], + "result": {"targetInfos": []} + }) + .to_string() + .into(), + )) + .unwrap(); }); - } - - #[test] - fn layout_undo_separates_resize_transactions_and_clients() { + let runtime = BrowserRuntime::connect_external_for_test(&format!( + "ws://{address}/devtools/browser/fake" + )) + .unwrap(); let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let options = mux.surface_options.lock().unwrap().clone(); + let surface = browser::new_surface( + 999, + "about:blank".to_string(), + (80, 24), + (8, 16), + &options, + Arc::downgrade(&mux), + ) + .unwrap(); + surface.as_browser().unwrap().install_shutdown_session_for_test( + runtime.clone(), + "target-1", + "session-1", + ); + insert_surface_checked(&mux, &mut mux.state.lock().unwrap(), surface.clone()).unwrap(); - assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.6, 1, 1)); - assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 1, 1)); - assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.8, 1, 2)); - assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.65, 2, 2)); - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); - mux.with_state(|state| { - assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.8); - }); + let removed = + take_surface_for_retirement(&mux, &mut mux.state.lock().unwrap(), surface.id).unwrap(); + mux.retire_surface_runtime(removed); + let attempted_during_close = query_rx.recv_timeout(Duration::from_millis(200)).is_ok(); - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); - mux.with_state(|state| { - assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.7); - }); + if !attempted_during_close { + mux.terminate_staged_shutdown_owners_until(Instant::now() + Duration::from_secs(1)); + query_rx + .recv_timeout(Duration::from_secs(1)) + .expect("cleanup retry did not reach the staged browser owner"); + } + runtime.shutdown(); + server.join().unwrap(); - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); - mux.with_state(|state| { - assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.5); - }); + assert!( + attempted_during_close, + "ordinary close lost the consume-once browser owner staged during removal" + ); + assert!(mux.shutdown_owners.is_empty()); } #[test] - fn layout_undo_separates_in_process_and_control_resize_owners_with_same_ids() { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - - // These model independent in-process and control-client entrypoints - // that happen to allocate the same numeric owner and transaction ids. - assert!( - mux.set_viewport_pane_width_in_process_transaction_checked(right_pane, 0.6, 1, 1) - .is_ok() - ); - assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 1, 1)); + fn shutdown_batches_target_discovery_per_browser_runtime() { + fn read_ws_json(ws: &mut tungstenite::WebSocket) -> Value { + loop { + match ws.read().unwrap() { + tungstenite::Message::Text(text) => { + return serde_json::from_str(&text).unwrap(); + } + tungstenite::Message::Binary(bytes) => { + return serde_json::from_slice(&bytes).unwrap(); + } + _ => {} + } + } + } - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); - mux.with_state(|state| { - assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.6); - }); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (query_count_tx, query_count_rx) = std::sync::mpsc::sync_channel(1); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + ws.send(tungstenite::Message::Text( + serde_json::json!({"id": discover["id"], "result": {}}).to_string().into(), + )) + .unwrap(); - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); - mux.with_state(|state| { - assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.5); + let mut query_count = 0; + let mut closed_targets = HashSet::new(); + while closed_targets.len() < 2 { + let request = read_ws_json(&mut ws); + match request["method"].as_str().unwrap() { + "Target.getTargets" => { + query_count += 1; + ws.send(tungstenite::Message::Text( + serde_json::json!({ + "id": request["id"], + "result": { + "targetInfos": [ + {"targetId": "target-1"}, + {"targetId": "target-2"} + ] + } + }) + .to_string() + .into(), + )) + .unwrap(); + } + "Target.closeTarget" => { + closed_targets + .insert(request["params"]["targetId"].as_str().unwrap().to_string()); + ws.send(tungstenite::Message::Text( + serde_json::json!({ + "id": request["id"], + "result": {"success": true} + }) + .to_string() + .into(), + )) + .unwrap(); + } + method => panic!("unexpected browser shutdown request: {method}"), + } + } + query_count_tx.send(query_count).unwrap(); }); - } + let runtime = BrowserRuntime::connect_external_for_test(&format!( + "ws://{address}/devtools/browser/fake" + )) + .unwrap(); + let mux = test_mux(); + let options = mux.surface_options.lock().unwrap().clone(); + for (id, target_id, session_id) in + [(999, "target-1", "session-1"), (1000, "target-2", "session-2")] + { + let surface = browser::new_surface( + id, + "about:blank".to_string(), + (80, 24), + (8, 16), + &options, + Arc::downgrade(&mux), + ) + .unwrap(); + surface.as_browser().unwrap().install_shutdown_session_for_test( + runtime.clone(), + target_id, + session_id, + ); + assert!(mux.shutdown_owners.stage_surface(&surface).is_some()); + } - #[test] - fn in_process_resize_owner_allocation_is_mux_scoped() { - let first = test_mux(); - assert_eq!(first.allocate_in_process_resize_owner(), 1); - assert_eq!(first.allocate_in_process_resize_owner(), 2); + let failed = + mux.terminate_staged_shutdown_owners_until(Instant::now() + Duration::from_secs(1)); + let query_count = query_count_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - let second = test_mux(); - assert_eq!(second.allocate_in_process_resize_owner(), 1); + runtime.shutdown(); + server.join().unwrap(); + assert!(!failed); + assert!(mux.shutdown_owners.is_empty()); + assert_eq!( + query_count, 1, + "shutdown repeated full target discovery for each browser surface" + ); } #[test] - fn layout_undo_separates_a_new_resize_from_pre_undo_history() { - let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + fn server_shutdown_uses_launched_runtime_when_target_confirmation_is_unreachable() { + fn read_ws_json(ws: &mut tungstenite::WebSocket) -> Value { + loop { + match ws.read().unwrap() { + tungstenite::Message::Text(text) => { + return serde_json::from_str(&text).unwrap(); + } + tungstenite::Message::Binary(bytes) => { + return serde_json::from_slice(&bytes).unwrap(); + } + _ => {} + } + } + } + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + let version = read_ws_json(&mut ws); + assert_eq!(version["method"], "Browser.getVersion"); + ws.send(tungstenite::Message::Text( + serde_json::json!({ + "id": version["id"], + "error": {"code": -32000, "message": "unavailable"} + }) + .to_string() + .into(), + )) + .unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + ws.send(tungstenite::Message::Text( + serde_json::json!({"id": discover["id"], "result": {}}).to_string().into(), + )) + .unwrap(); + }); + let runtime = BrowserRuntime::connect_launched_for_test(&format!( + "ws://{address}/devtools/browser/fake" + )) + .unwrap(); + server.join().unwrap(); + let disconnect_deadline = Instant::now() + Duration::from_secs(1); + while !runtime.is_closed() && Instant::now() < disconnect_deadline { + std::thread::yield_now(); + } + assert!(runtime.is_closed(), "fixture did not make target confirmation unreachable"); - assert!(mux.set_viewport_pane_width(right_pane, 0.6)); - assert!(mux.set_viewport_pane_width(first_pane, 0.9)); - assert!(matches!( - mux.undo_layout(first_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); + let mux = test_mux(); + let options = mux.surface_options.lock().unwrap().clone(); + let surface = browser::new_surface( + 999, + "about:blank".to_string(), + (80, 24), + (8, 16), + &options, + Arc::downgrade(&mux), + ) + .unwrap(); + surface.as_browser().unwrap().install_shutdown_session_for_test( + runtime.clone(), + "target-1", + "session-1", + ); + insert_surface_checked(&mux, &mut mux.state.lock().unwrap(), surface).unwrap(); + mux.browser_runtime.install_for_test(runtime); - assert!(mux.set_viewport_pane_width(right_pane, 0.7)); - assert!(matches!( - mux.undo_layout(right_pane, None, false).unwrap(), - LayoutUndoResult::Undone { .. } - )); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.layout_columns[0].width, 1.0); - assert_eq!(screen.layout_columns[1].width, 0.6); - }); + let result = mux.close_all_surfaces_for_shutdown(); + + assert!( + result.is_ok(), + "owned launched browser was not used as the authoritative shutdown fallback: {result:?}" + ); + assert!(mux.shutdown_owners.is_empty()); + assert!(!mux.browser_runtime.has_runtime_for_test()); } #[test] - fn closing_a_pane_invalidates_layout_undo_history() { + fn server_shutdown_cannot_pass_an_in_flight_surface_retirement() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - - assert!(mux.close_pane(right_pane).unwrap()); - let error = mux.undo_layout(first_pane, None, false).unwrap_err(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let surface_id = surface.id; + let owned = mux.surface(surface.id).unwrap(); + let retirement_gate = Arc::new(crate::surface::TestLifecycleGate::default()); + owned.set_server_shutdown_gate_for_test(retirement_gate.clone()); + let close = std::thread::spawn({ + let mux = mux.clone(); + move || mux.close_surface(surface_id).unwrap() + }); + assert!(retirement_gate.wait_until_entered(1, Instant::now() + Duration::from_secs(1))); + assert!(mux.surface(surface_id).is_none(), "ordinary close did not remove its surface"); - assert_eq!(error.to_string(), "no layout change to undo"); - assert_terminal_view_detached(&mux, right.id); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert!(!screen.layout_columns_active()); - assert!(screen.layout_column_projection_is_consistent()); + let (shutdown_tx, shutdown_rx) = std::sync::mpsc::sync_channel(1); + let shutdown_worker = std::thread::spawn({ + let mux = mux.clone(); + move || shutdown_tx.send(mux.close_all_surfaces_for_shutdown()).unwrap() }); + assert!( + shutdown_rx.recv_timeout(Duration::from_millis(100)).is_err(), + "server shutdown passed an in-flight retirement owner" + ); + retirement_gate.release(); + let shutdown = shutdown_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + assert!(close.join().unwrap()); + shutdown_worker.join().unwrap(); + owned.set_server_shutdown_failure_for_test(false); + mux.close_all_surfaces_for_shutdown().unwrap(); + assert!(mux.shutdown_owners.is_empty()); + let error = shutdown.expect_err("server shutdown passed an untracked retirement owner"); + assert!(error.to_string().contains("could not terminate 1 surface process")); } #[test] - fn closing_the_base_viewport_column_preserves_the_promoted_width() { + fn shutdown_request_watch_is_signaled_and_cancellable() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); - let right = mux.new_pane_right(middle_pane, 0.4, Some((30, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - assert!(mux.set_viewport_pane_width(first_pane, 0.75)); - - assert!(mux.close_pane(first_pane).unwrap()); - - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.viewport_base_width, Some(0.5)); - assert_eq!( - screen.root.viewport_column_owner(middle_pane, &screen.viewport_splits), - Some(ViewportColumn::Base) - ); - let ViewportColumn::Split(right_split) = - screen.root.viewport_column_owner(right_pane, &screen.viewport_splits).unwrap() - else { - panic!("right pane must remain an appended column"); - }; - assert_eq!(screen.viewport_splits[&right_split], 0.4); - let Node::Split { ratio, .. } = &screen.root else { - panic!("two viewport columns must retain one split"); - }; - assert!((*ratio - (0.5 / 0.9)).abs() < 0.0001); - }); + let watch = mux.watch_shutdown_request(); + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + let waiter = std::thread::spawn(move || result_tx.send(watch.wait()).unwrap()); + assert!(result_rx.recv_timeout(Duration::from_millis(50)).is_err()); + + mux.request_shutdown(); + + assert!(result_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + waiter.join().unwrap(); + assert!(mux.watch_shutdown_request().wait()); + + let next = mux.watch_next_shutdown_request(); + let (next_tx, next_rx) = std::sync::mpsc::sync_channel(1); + let next_waiter = std::thread::spawn(move || next_tx.send(next.wait()).unwrap()); + assert!(next_rx.recv_timeout(Duration::from_millis(50)).is_err()); + mux.request_shutdown(); + assert!(next_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + next_waiter.join().unwrap(); + + let fresh = test_mux(); + let watch = fresh.watch_shutdown_request(); + let cancel = watch.clone(); + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + let waiter = std::thread::spawn(move || result_tx.send(watch.wait()).unwrap()); + cancel.cancel(); + assert!(!result_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + waiter.join().unwrap(); } #[test] - fn closing_a_middle_viewport_column_recomputes_fallback_ratios() { + fn ordinary_surface_close_does_not_retain_terminal_render_state() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((80, 22))).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); - let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); - let right = mux.new_pane_right(middle_pane, 0.4, Some((30, 22))).unwrap(); - let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); - assert!(mux.set_viewport_pane_width(first_pane, 0.75)); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let owned = mux.surface(surface.id).unwrap(); + owned.set_server_shutdown_failure_for_test(true); + let before_close = Arc::strong_count(&owned); - assert!(mux.close_pane(middle_pane).unwrap()); + assert!(mux.close_surface(surface.id).unwrap()); + wait_for_kitty_image_budget(&mux); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.viewport_base_width, Some(0.75)); - let ViewportColumn::Split(right_split) = - screen.root.viewport_column_owner(right_pane, &screen.viewport_splits).unwrap() - else { - panic!("right pane must remain appended"); - }; - assert_eq!(screen.viewport_splits.len(), 1); - assert_eq!(screen.viewport_splits[&right_split], 0.4); - let Node::Split { ratio, .. } = &screen.root else { - panic!("two viewport columns must retain one split"); - }; - assert!((*ratio - (0.75 / 1.15)).abs() < 0.0001); - }); + assert!( + Arc::strong_count(&owned) < before_close, + "shutdown escrow retained the heavyweight Surface after graphics cleanup" + ); } - #[cfg(unix)] #[test] - fn discard_spawned_restores_unbound_running_terminal_when_registry_close_fails() { - const TERMINAL: &str = "00000000000040008000000000000012"; - const INCARNATION: &str = "10000000000040008000000000000012"; + fn server_shutdown_uses_bounded_parallel_termination_fanout() { let mux = test_mux(); - let workspace = mux - .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001012".into()), None) - .unwrap(); - let surface = - insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); - { - let mut state = mux.state.lock().unwrap(); - let (removed, split_index_dirty) = remove_surface(&mux, &mut state, surface.id); - if split_index_dirty { - Mux::rebuild_split_screen_index(&mut state); - } - insert_surface_checked( - &mut state, - removed.expect("seeded terminal is removable from topology"), - ) - .unwrap(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + for _ in 1..64 { + mux.new_tab(Some(pane), None, None).unwrap(); + } + let surfaces = mux.with_state(|state| { + state.panes[&pane] + .tabs + .iter() + .map(|surface| state.surfaces[surface].clone()) + .collect::>() + }); + let shutdown_gate = Arc::new(crate::surface::TestLifecycleGate::default()); + for surface in &surfaces { + surface.set_server_shutdown_gate_for_test(shutdown_gate.clone()); } - assert_eq!(mux.with_state(|state| state.pane_of(surface.id)), None); - let events = mux.subscribe(); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); - - mux.discard_spawned(vec![surface.clone()]); - let restored = mux - .with_state(|state| state.pane_of(surface.id)) - .expect("failed close must project the terminal back into reachable topology"); - assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() - .lifecycle, - TerminalLifecycle::Running + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + let close_mux = mux.clone(); + let close = std::thread::spawn(move || { + result_tx.send(close_mux.close_all_surfaces_for_shutdown()).unwrap(); + }); + assert!( + shutdown_gate.wait_until_entered( + SHUTDOWN_FANOUT_WORKERS, + Instant::now() + Duration::from_secs(1) + ), + "bulk close did not use the bounded parallel shutdown fanout" ); - assert!(events.try_iter().any(|event| { - matches!(event, MuxEvent::Status(message) - if message.contains("could not atomically close discarded terminals")) - })); + shutdown_gate.release(); + let error = result_rx + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .expect_err("gated terminal owners unexpectedly completed shutdown"); + assert!(error.to_string().contains("could not terminate 64 surface process")); + close.join().unwrap(); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); - assert!(mux.close_pane_for_resource_effect(restored).unwrap()); + for surface in &surfaces { + surface.set_server_shutdown_failure_for_test(false); + } + mux.close_all_surfaces_for_shutdown().unwrap(); + assert!(mux.shutdown_owners.is_empty()); } #[cfg(unix)] #[test] - fn close_pane_detaches_view_without_closing_terminal_host() { - const TERMINAL: &str = "00000000000040008000000000000010"; - const INCARNATION: &str = "10000000000040008000000000000010"; - let mux = test_mux(); - let workspace = mux - .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001002".into()), None) - .unwrap(); - let surface = - insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); - let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); - - assert!(mux.close_pane(pane).unwrap()); - assert_eq!(mux.with_state(|state| state.pane_of(surface.id)), None); - assert!(mux.surface(surface.id).is_some()); - assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() - .lifecycle, - TerminalLifecycle::Running + fn server_shutdown_reuses_one_deadline_bounded_record_scan() { + let root = std::env::temp_dir().join(format!( + "cmux-shutdown-record-scan-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let mux = Mux::new_for_test( + "shutdown-record-scan", + SurfaceOptions { terminal_host_root: Some(root.clone()), ..SurfaceOptions::default() }, ); - assert!(mux.close_terminal(TERMINAL, INCARNATION).is_err()); + let calls = Arc::new(AtomicUsize::new(0)); + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let release_rx = Arc::new(Mutex::new(release_rx)); + *mux.terminal_host_record_loader.lock().unwrap() = Some(Arc::new({ + let calls = calls.clone(); + move |_, _, _| { + calls.fetch_add(1, Ordering::AcqRel); + started_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + Ok(Vec::new()) + } + })); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); - mux.close_terminal(TERMINAL, INCARNATION).unwrap(); - assert!(mux.surface(surface.id).is_none()); - assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() - .lifecycle, - TerminalLifecycle::Tombstoned + let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1); + let first = std::thread::spawn({ + let mux = mux.clone(); + move || { + done_tx + .send(mux.close_all_surfaces_for_shutdown_until( + Instant::now() + Duration::from_millis(50), + )) + .unwrap(); + } + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let first_result = done_rx.recv_timeout(Duration::from_millis(150)).ok(); + let completed_at_deadline = first_result.as_ref().is_some_and(Result::is_err); + let reused_scan = if completed_at_deadline { + let second = mux + .close_all_surfaces_for_shutdown_until(Instant::now() + Duration::from_millis(50)); + let starts = calls.load(Ordering::Acquire); + for _ in 0..2 { + release_tx.send(()).unwrap(); + } + let third = + mux.close_all_surfaces_for_shutdown_until(Instant::now() + Duration::from_secs(1)); + second.is_err() && starts == 1 && third.is_ok() + } else { + for _ in 0..3 { + release_tx.send(()).unwrap(); + } + let _ = done_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + false + }; + first.join().unwrap(); + let _ = std::fs::remove_dir_all(root); + + assert!( + completed_at_deadline, + "server shutdown remained blocked inside the terminal-host record scan" ); + assert!(reused_scan, "a shutdown retry spawned another blocked terminal-host record scan"); } #[cfg(unix)] #[test] - fn close_screen_detaches_view_without_closing_terminal_host() { - const TERMINAL: &str = "00000000000040008000000000000011"; - const INCARNATION: &str = "10000000000040008000000000000011"; - let mux = test_mux(); - let workspace = mux - .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001003".into()), None) - .unwrap(); - let surface = - insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); - let screen = mux.with_state(|state| surface_screen_id(state, surface.id).unwrap()); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); + fn server_shutdown_waits_for_unpublished_child_reaper_ownership() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_UNPUBLISHED_CHILD_REAPER_SHUTDOWN"; + const TEST_NAME: &str = + "mux::tests::server_shutdown_waits_for_unpublished_child_reaper_ownership"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "shutdown ownership subprocess failed: {status}"); + return; + } - assert!(mux.close_screen(screen).unwrap()); - assert_eq!(mux.with_state(|state| surface_screen_id(state, surface.id)), None); - assert!(mux.surface(surface.id).is_some()); + let mux = test_mux(); + let reservation = crate::process_session::reserve_child_reaper().unwrap(); + assert_eq!( + mux.shutdown_cleanup_health(), + ShutdownCleanupHealth { pending: 1, retrying: true, degraded: false } + ); + let error = mux + .close_all_surfaces_for_shutdown_until(Instant::now() + Duration::from_millis(50)) + .unwrap_err(); + + assert!(format!("{error:#}").contains("child reaper")); assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() - .lifecycle, - TerminalLifecycle::Running + mux.shutdown_cleanup_health(), + ShutdownCleanupHealth { pending: 1, retrying: false, degraded: true } ); - assert!(mux.close_terminal(TERMINAL, INCARNATION).is_err()); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); - mux.close_terminal(TERMINAL, INCARNATION).unwrap(); - assert!(mux.surface(surface.id).is_none()); + drop(reservation); assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() - .lifecycle, - TerminalLifecycle::Tombstoned + mux.close_all_surfaces_for_shutdown_until(Instant::now() + Duration::from_secs(1)) + .unwrap(), + 0 + ); + assert_eq!( + mux.shutdown_cleanup_health(), + ShutdownCleanupHealth { pending: 0, retrying: false, degraded: false } ); } #[test] - fn zellij_new_pane_uses_creation_order_after_manual_split() { + fn active_synchronous_cleanup_reports_staged_owners_as_retrying() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let p1 = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let second = mux.new_pane(p1, None).unwrap(); - let p2 = mux.with_state(|state| state.pane_of(second.id).unwrap()); - let third = mux.split(p1, SplitDir::Down, None).unwrap(); - let p3 = mux.with_state(|state| state.pane_of(third.id).unwrap()); - let fourth = mux.new_pane(p3, None).unwrap(); - let p4 = mux.with_state(|state| state.pane_of(fourth.id).unwrap()); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let owned = mux.surface(surface.id).unwrap(); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - let mut order = Vec::new(); - screen.root.pane_ids(&mut order); - assert_eq!(order, vec![p1, p2, p3, p4]); - assert_eq!(screen.zellij_auto_layout.as_deref(), Some(order.as_slice())); - }); + mux.schedule_server_shutdown_cleanup(); + assert!(mux.shutdown_owners.stage_surface(&owned).is_some()); + let health = mux.shutdown_cleanup_health(); + mux.complete_server_shutdown_cleanup(); + assert!(mux.close_surface(surface.id).unwrap()); + + assert_eq!(health, ShutdownCleanupHealth { pending: 1, retrying: true, degraded: false }); } + #[cfg(unix)] #[test] - fn zellij_new_pane_exits_zoom_before_focusing_the_new_pane() { - let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - mux.zoom_pane(Some(first_pane), ZoomMode::On).unwrap(); + fn server_shutdown_rejects_malformed_host_records_before_removing_topology() { + let root = std::env::temp_dir().join(format!( + "cmux-shutdown-host-record-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("00000000000040008000000000000031.json"), b"{not-valid-json") + .unwrap(); + let mux = Mux::new_for_test( + "strict-shutdown-records", + SurfaceOptions { terminal_host_root: Some(root.clone()), ..SurfaceOptions::default() }, + ); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - let new_surface = mux.new_pane(first_pane, None).unwrap(); - let new_pane = mux.with_state(|state| state.pane_of(new_surface.id).unwrap()); + let result = mux.close_all_surfaces_for_shutdown(); + let topology_retained = mux.surface(surface.id).is_some(); + let _ = std::fs::remove_dir_all(&root); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.active_pane, new_pane); - assert_eq!(screen.zoomed_pane, None); - }); + let error = result.unwrap_err(); + assert!(format!("{error:#}").contains("load terminal hosts for server shutdown")); + assert!(topology_retained); + assert_eq!( + mux.shutdown_cleanup_health(), + ShutdownCleanupHealth { pending: 1, retrying: false, degraded: true } + ); } + #[cfg(unix)] #[test] - fn zellij_new_pane_emits_pane_added_delta_and_layout_change() { - let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let (workspace, screen, first_pane) = mux.with_state(|state| { - let workspace = &state.workspaces[0]; - let screen = &workspace.screens[0]; - (workspace.id, screen.id, state.pane_of(first.id).unwrap()) - }); - let events = mux.subscribe(); + fn server_shutdown_rejects_host_records_beyond_owner_capacity() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; - let added = mux.new_pane(first_pane, None).unwrap(); - let added_pane = mux.with_state(|state| state.pane_of(added.id).unwrap()); + let root = std::env::temp_dir().join(format!( + "cmux-shutdown-host-capacity-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let terminal_id = TerminalId::random().unwrap().to_hex(); + let record = crate::terminal_host_runtime::TerminalHostRecord { + record_version: 1, + terminal_id: terminal_id.clone(), + incarnation: crate::terminal_host::HostIncarnation::random().unwrap().to_hex(), + endpoint: format!( + "/tmp/cmux-th-{}/{}.sock", + std::fs::metadata(&root).unwrap().uid(), + terminal_id + ), + owner_token: "01".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: String::new(), + supports_set_defaults: false, + supports_terminate_only: false, + supports_terminate_ack: false, + supports_clear_history: false, + }; + let record_path = record.record_path(&root); + std::fs::write(&record_path, serde_json::to_vec(&record).unwrap()).unwrap(); + std::fs::set_permissions(&record_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let mux = Mux::new_for_test( + "strict-shutdown-capacity", + SurfaceOptions { terminal_host_root: Some(root.clone()), ..SurfaceOptions::default() }, + ); + mux.set_shutdown_owner_capacity_for_test(0); - let deadline = Instant::now() + Duration::from_secs(1); - let mut saw_added = false; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - match events.recv_timeout(remaining).expect("pane creation events arrive") { - MuxEvent::TreeDelta(TreeDelta { - kind: TreeDeltaKind::PaneAdded, - workspace: event_workspace, - screen: Some(event_screen), - pane: Some(event_pane), - surface: None, - index: Some(1), - .. - }) if event_workspace == workspace - && event_screen == screen - && event_pane == added_pane => - { - saw_added = true; - } - MuxEvent::LayoutChanged(event_screen) if saw_added && event_screen == screen => { - break; - } - MuxEvent::LayoutChanged(_) if !saw_added => { - panic!("layout invalidation arrived before the pane-added delta") - } - _ => {} - } - } - assert!(events.try_iter().all(|event| !matches!(event, MuxEvent::TreeChanged))); + let error = mux.close_all_surfaces_for_shutdown().unwrap_err(); + let _ = std::fs::remove_dir_all(&root); + + assert!( + format!("{error:#}").contains("capacity"), + "shutdown staged a host beyond owner capacity: {error:#}" + ); } + #[cfg(unix)] #[test] - fn closing_zellij_pane_reapplies_layout_for_remaining_count() { - let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let mut surfaces = vec![first]; - let mut active = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); - for _ in 0..4 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - surfaces.push(surface); + fn rejected_shutdown_preflight_preserves_browser_ownership() { + fn read_ws_json(ws: &mut tungstenite::WebSocket) -> Value { + loop { + match ws.read().unwrap() { + tungstenite::Message::Text(text) => { + return serde_json::from_str(&text).unwrap(); + } + tungstenite::Message::Binary(bytes) => { + return serde_json::from_slice(&bytes).unwrap(); + } + _ => {} + } + } } - mux.close_surface(surfaces[0].id).unwrap(); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - let order = screen.zellij_auto_layout.as_ref().unwrap(); - assert_eq!(order.len(), 4); - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 200, height: 40 }, - Some(screen.active_pane), - ); - assert_eq!(layout.rect_of(order[0]).unwrap().height, 40); - let right_heights = order[1..] - .iter() - .map(|pane| layout.rect_of(*pane).unwrap().height) - .collect::>(); - assert_eq!(right_heights, vec![13, 14, 13]); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + ws.send(tungstenite::Message::Text( + serde_json::json!({"id": discover["id"], "result": {}}).to_string().into(), + )) + .unwrap(); }); - } - - #[test] - fn closing_zellij_stack_pane_keeps_active_pane_expanded() { + let runtime = BrowserRuntime::connect_external_for_test(&format!( + "ws://{address}/devtools/browser/fake" + )) + .unwrap(); + server.join().unwrap(); + assert!( + runtime.wait_until_closed(Instant::now() + Duration::from_secs(1)), + "test browser runtime did not observe disconnect" + ); let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let mut surfaces = vec![first]; - let mut active = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); - for _ in 1..14 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - surfaces.push(surface); - } - let leading_pane = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); - let active_stack_pane = mux.with_state(|state| state.pane_of(surfaces[2].id).unwrap()); - assert!(mux.focus_pane(active_stack_pane)); + let options = mux.surface_options.lock().unwrap().clone(); + let surface = browser::new_surface( + 999, + "about:blank".to_string(), + (80, 24), + (8, 16), + &options, + Arc::downgrade(&mux), + ) + .unwrap(); + surface.as_browser().unwrap().install_shutdown_session_for_test( + runtime.clone(), + "target-preflight", + "session-preflight", + ); + insert_surface_checked(&mux, &mut mux.state.lock().unwrap(), surface.clone()).unwrap(); + mux.set_shutdown_owner_capacity_for_test(0); - mux.close_surface(surfaces[1].id).unwrap(); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.active_pane, active_stack_pane); - assert!(matches!( - &screen.root, - Node::Split { dir: SplitDir::Right, a, b, .. } - if matches!(a.as_ref(), Node::Leaf(pane) if *pane == leading_pane) - && matches!(b.as_ref(), Node::Stack { panes, .. } if panes.contains(&active_stack_pane)) - )); - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 80, height: 40 }, - Some(screen.active_pane), - ); - assert!(!layout.stacked_headers.contains(&active_stack_pane)); - assert!(layout.rect_of(active_stack_pane).unwrap().height > 1); - }); + let error = mux.close_all_surfaces_for_shutdown().unwrap_err(); + let topology_retained = mux.surface(surface.id).is_some(); + let surface_was_live = !surface.is_dead(); + let owner_retained = surface.shutdown_owner().is_some(); + runtime.shutdown(); + + assert!(format!("{error:#}").contains("capacity")); + assert!(topology_retained, "shutdown preflight removed the browser surface"); + assert!(surface_was_live, "shutdown preflight killed the retained browser surface"); + assert!(owner_retained, "shutdown preflight consumed the browser target owner"); } + #[cfg(unix)] #[test] - fn rebuilding_zellij_layout_preserves_stack_expansion_while_focus_is_elsewhere() { + fn server_shutdown_preflights_process_control_before_removing_topology() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let mut surfaces = vec![first]; - let mut active = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); - for _ in 1..14 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - surfaces.push(surface); - } - let leading_pane = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); - let expanded_stack_pane = mux.with_state(|state| state.pane_of(surfaces[2].id).unwrap()); - assert!(mux.focus_pane(expanded_stack_pane)); - assert!(mux.focus_pane(leading_pane)); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + crate::process_session::set_process_session_preflight_failure_for_test(true); - mux.close_surface(surfaces[1].id).unwrap(); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 80, height: 40 }, - Some(screen.active_pane), - ); - assert!(!layout.stacked_headers.contains(&expanded_stack_pane)); - assert!(layout.rect_of(expanded_stack_pane).unwrap().height > 1); - }); + let result = mux.close_all_surfaces_for_shutdown(); + + crate::process_session::set_process_session_preflight_failure_for_test(false); + assert!(result.is_err(), "server shutdown ignored an unavailable process-control runtime"); + assert!( + mux.surface(surface.id).is_some(), + "server shutdown removed topology before process-control preflight" + ); } #[test] - fn moving_zellij_stack_pane_keeps_target_pane_expanded() { + fn server_shutdown_waits_for_active_async_surface_creation() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let mut surfaces = vec![first]; - let mut active = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); - for _ in 1..14 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - surfaces.push(surface); - } - let leading_pane = mux.with_state(|state| state.pane_of(surfaces[0].id).unwrap()); - let target = mux.with_state(|state| state.pane_of(surfaces[2].id).unwrap()); - let events = mux.subscribe(); + let creation = mux.async_surface_creations.begin().unwrap(); - assert!(mux.move_tab(surfaces[1].id, target, 0)); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.active_pane, target); - assert!(matches!( - &screen.root, - Node::Split { dir: SplitDir::Right, a, b, .. } - if matches!(a.as_ref(), Node::Leaf(pane) if *pane == leading_pane) - && matches!(b.as_ref(), Node::Stack { panes, .. } if panes.contains(&target)) - )); - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 80, height: 40 }, - Some(screen.active_pane), - ); - assert!(!layout.stacked_headers.contains(&target)); - assert!(layout.rect_of(target).unwrap().height > 1); + let (shutdown_done_tx, shutdown_done_rx) = std::sync::mpsc::sync_channel(1); + let shutdown = std::thread::spawn({ + let mux = mux.clone(); + move || { + shutdown_done_tx.send(mux.close_all_surfaces_for_shutdown()).unwrap(); + } }); - assert!(events.try_iter().any(|event| matches!(event, MuxEvent::LayoutChanged(_)))); + assert!(shutdown_done_rx.recv_timeout(Duration::from_millis(100)).is_err()); + + drop(creation); + assert_eq!(shutdown_done_rx.recv_timeout(Duration::from_secs(1)).unwrap().unwrap(), 0); + shutdown.join().unwrap(); + assert!(!mux.browser_runtime.has_runtime_for_test()); } #[test] - fn swapping_zellij_stack_panes_keeps_active_pane_expanded() { + fn daemon_exit_waits_for_async_surface_creation_release() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut active = first_pane; - for _ in 1..13 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - } + let creation = mux.async_surface_creations.begin().unwrap(); - assert!(mux.swap_panes(active, first_pane)); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.active_pane, active); - assert!(screen.zellij_auto_layout.is_none()); - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 80, height: 40 }, - Some(screen.active_pane), - ); - assert!(!layout.stacked_headers.contains(&active)); - assert!(layout.rect_of(active).unwrap().height > 1); + let (shutdown_done_tx, shutdown_done_rx) = std::sync::mpsc::sync_channel(1); + let shutdown = std::thread::spawn({ + let mux = mux.clone(); + move || { + shutdown_done_tx.send(mux.shutdown()).unwrap(); + } }); + assert!( + mux.async_surface_creations + .wait_until_stopping(Instant::now() + Duration::from_secs(1)), + "daemon exit did not stop the async creation owner" + ); + assert!( + matches!(shutdown_done_rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)), + "daemon exit completed while an async creation still owned in-flight work" + ); + + drop(creation); + shutdown_done_rx + .recv_timeout(crate::test_timeout(Duration::from_secs(2))) + .unwrap() + .unwrap(); + shutdown.join().unwrap(); + + assert!(!mux.browser_runtime.has_runtime_for_test()); } #[test] - fn closing_active_pane_in_damaged_stack_expands_replacement() { + fn daemon_exit_retains_active_local_owner_until_termination_succeeds() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut active_surface = first; - let mut active = first_pane; - for _ in 1..14 { - active_surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(active_surface.id).unwrap()); - } - assert!(mux.swap_panes(active, first_pane)); - - mux.close_surface(active_surface.id).unwrap(); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert!(screen.zellij_auto_layout.is_none()); - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 80, height: 40 }, - Some(screen.active_pane), - ); - assert!(!layout.stacked_headers.contains(&screen.active_pane)); - assert!(layout.rect_of(screen.active_pane).unwrap().height > 1); + mux.set_shutdown_attempt_timeout_for_test(Duration::from_millis(25)); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let (failing, attempts) = surface.set_recovering_server_shutdown_for_test(); + let (shutdown_done_tx, shutdown_done_rx) = std::sync::mpsc::sync_channel(1); + let shutdown = std::thread::spawn({ + let mux = mux.clone(); + move || { + mux.shutdown().unwrap(); + shutdown_done_tx.send(()).unwrap(); + } }); + assert!( + attempts.wait_until(1, Instant::now() + Duration::from_secs(1)), + "daemon shutdown did not attempt local process termination" + ); + let retained = mux.shutdown_owners.len(); + + failing.store(false, Ordering::Release); + shutdown_done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + shutdown.join().unwrap(); + + assert_eq!( + retained, 1, + "daemon exit discarded the active local process owner after termination failed" + ); + assert!(mux.shutdown_owners.is_empty()); } #[test] - fn focusing_zellij_stack_header_expands_that_pane() { + fn daemon_exit_bounds_permanent_cleanup_failures() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let mut active = mux.with_state(|state| state.pane_of(first.id).unwrap()); - for _ in 1..13 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - } - let stack_pane = mux.with_state(|state| { - state.workspaces[0].screens[0].zellij_auto_layout.as_ref().unwrap()[1] + mux.set_shutdown_attempt_timeout_for_test(Duration::from_millis(25)); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let (failing, _attempts) = surface.set_recovering_server_shutdown_for_test(); + let (shutdown_done_tx, shutdown_done_rx) = std::sync::mpsc::sync_channel(1); + let shutdown = std::thread::spawn(move || { + shutdown_done_tx.send(mux.shutdown()).unwrap(); }); - assert!(mux.focus_pane(stack_pane)); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert_eq!(screen.active_pane, stack_pane); - assert!(matches!( - &screen.root, - Node::Split { dir: SplitDir::Right, b, .. } - if matches!(b.as_ref(), Node::Stack { panes, .. } if panes.contains(&stack_pane)) - )); - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 80, height: 40 }, - Some(screen.active_pane), - ); - assert!(!layout.stacked_headers.contains(&stack_pane)); - assert!(layout.rect_of(stack_pane).unwrap().height > 1); + let completed_before_deadline = + shutdown_done_rx.recv_timeout(Duration::from_millis(400)).is_ok(); + failing.store(false, Ordering::Release); + if !completed_before_deadline { + let _ = shutdown_done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + } + shutdown.join().unwrap(); + + assert!( + completed_before_deadline, + "daemon exit retried a permanent cleanup failure without a total deadline" + ); + } + + #[test] + fn browser_runtime_connection_does_not_hold_the_shared_slot() { + let slot = Arc::new(BrowserRuntimeSlot::default()); + let (connect_started_tx, connect_started_rx) = std::sync::mpsc::sync_channel(1); + let (release_connect_tx, release_connect_rx) = std::sync::mpsc::sync_channel(1); + let (connect_done_tx, connect_done_rx) = std::sync::mpsc::sync_channel(1); + let connect = std::thread::spawn({ + let slot = slot.clone(); + move || { + let result = slot.get_or_connect( + || false, + |_| false, + || -> anyhow::Result> { + connect_started_tx.send(()).unwrap(); + release_connect_rx.recv().unwrap(); + anyhow::bail!("test connector released") + }, + ); + connect_done_tx.send(result.is_err()).unwrap(); + } }); + connect_started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let slot_available = slot.lock_available_for_test(); + release_connect_tx.send(()).unwrap(); + let connect_failed = connect_done_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + connect.join().unwrap(); + + assert!( + slot_available, + "browser connection setup held the shared runtime slot across blocking work" + ); + assert!(connect_failed, "test connector result was not published"); } #[test] - fn focusing_outside_a_stack_emits_layout_changed() { - let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut active = first_pane; - for _ in 1..13 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + fn browser_runtime_connection_does_not_publish_after_shutdown_starts() { + fn read_ws_json(ws: &mut tungstenite::WebSocket) -> Value { + loop { + match ws.read().unwrap() { + tungstenite::Message::Text(text) => { + return serde_json::from_str(&text).unwrap(); + } + tungstenite::Message::Binary(bytes) => { + return serde_json::from_slice(&bytes).unwrap(); + } + _ => {} + } + } } - let stack_pane = mux.with_state(|state| { - state.workspaces[0].screens[0].zellij_auto_layout.as_ref().unwrap()[1] - }); - let outside = mux.split(active, SplitDir::Right, None).unwrap(); - let outside_pane = mux.with_state(|state| state.pane_of(outside.id).unwrap()); - assert!(mux.focus_pane(stack_pane)); - let events = mux.subscribe(); - assert!(mux.focus_pane(outside_pane)); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 80, height: 40 }, - Some(screen.active_pane), - ); - assert!(!layout.stacked_headers.contains(&stack_pane)); - assert!(layout.rect_of(stack_pane).unwrap().height > 1); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + let discover = read_ws_json(&mut ws); + assert_eq!(discover["method"], "Target.setDiscoverTargets"); + ws.send(tungstenite::Message::Text( + serde_json::json!({"id": discover["id"], "result": {}}).to_string().into(), + )) + .unwrap(); + while ws.read().is_ok() {} }); - let invalidations = events - .try_iter() - .filter(|event| matches!(event, MuxEvent::TreeChanged | MuxEvent::LayoutChanged(_))) - .collect::>(); - assert_eq!(invalidations.len(), 1); - assert!(matches!(invalidations[0], MuxEvent::LayoutChanged(_))); + let runtime = BrowserRuntime::connect_external_for_test(&format!( + "ws://{address}/devtools/browser/fake" + )) + .unwrap(); + let slot = Arc::new(BrowserRuntimeSlot::default()); + let shutting_down = Arc::new(AtomicBool::new(false)); + let (connect_started_tx, connect_started_rx) = std::sync::mpsc::sync_channel(1); + let (release_connect_tx, release_connect_rx) = std::sync::mpsc::sync_channel(1); + let (connect_done_tx, connect_done_rx) = std::sync::mpsc::sync_channel(1); + let connect = std::thread::spawn({ + let slot = slot.clone(); + let shutting_down = shutting_down.clone(); + let runtime = runtime.clone(); + move || { + let result = slot.get_or_connect( + || shutting_down.load(Ordering::Acquire), + |_| false, + || { + connect_started_tx.send(()).unwrap(); + release_connect_rx.recv().unwrap(); + Ok(runtime) + }, + ); + connect_done_tx.send(result.is_err()).unwrap(); + } + }); + connect_started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + slot.stop_with_fence(|| shutting_down.store(true, Ordering::Release)); + release_connect_tx.send(()).unwrap(); + assert!(connect_done_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + connect.join().unwrap(); + assert!( + !slot.has_runtime_for_test(), + "browser runtime was published after shutdown started" + ); + assert!( + runtime.wait_until_closed(Instant::now() + Duration::from_secs(1)), + "browser runtime created during shutdown was not closed" + ); + server.join().unwrap(); } #[test] - fn directional_split_of_zellij_stack_preserves_requested_direction() { - let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut active = first_pane; - for _ in 1..13 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + fn shutdown_fanout_runs_terminations_concurrently() { + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(8); + let worker = std::thread::spawn({ + let gate = gate.clone(); + move || { + bounded_shutdown_fanout( + &[(); 8], + Instant::now() + Duration::from_secs(1), + |_, _| { + started_tx.send(()).unwrap(); + let (released, wake) = &*gate; + let released = wake + .wait_while(released.lock().unwrap(), |released| !*released) + .unwrap(); + drop(released); + }, + ); + } + }); + + let deadline = Instant::now() + Duration::from_secs(1); + let mut started = 0; + while started < 8 { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { break }; + if started_rx.recv_timeout(remaining).is_err() { + break; + } + started += 1; } + let (released, wake) = &*gate; + *released.lock().unwrap() = true; + wake.notify_all(); + worker.join().unwrap(); + assert_eq!(started, 8); + } - let split = mux.split(active, SplitDir::Right, None).unwrap(); - let split_pane = mux.with_state(|state| state.pane_of(split.id).unwrap()); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert!(matches!( - &screen.root, - Node::Split { dir: SplitDir::Right, a, b, .. } - if matches!(a.as_ref(), Node::Leaf(pane) if *pane == first_pane) - && matches!( - b.as_ref(), - Node::Split { dir: SplitDir::Right, a, b, .. } - if matches!(a.as_ref(), Node::Stack { .. }) - && matches!(b.as_ref(), Node::Leaf(pane) if *pane == split_pane) - ) - )); - assert!(screen.zellij_auto_layout.is_none()); + #[test] + fn shutdown_fanout_reuses_one_deadline_across_worker_batches() { + let deadline = Instant::now() + Duration::from_secs(1); + let observed = Mutex::new(Vec::new()); + + bounded_shutdown_fanout( + &[(); SHUTDOWN_FANOUT_WORKERS * 2 + 1], + deadline, + |_, item_deadline| { + observed.lock().unwrap().push(item_deadline); + }, + ); + + let observed = observed.into_inner().unwrap(); + assert_eq!(observed.len(), SHUTDOWN_FANOUT_WORKERS * 2 + 1); + assert!(observed.into_iter().all(|item_deadline| item_deadline == deadline)); + } + + #[test] + fn shutdown_fanout_does_not_claim_another_batch_after_the_deadline() { + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let expired = Arc::new(AtomicBool::new(false)); + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(SHUTDOWN_FANOUT_WORKERS + 1); + let deadline = Instant::now() + Duration::from_millis(250); + let worker = std::thread::spawn({ + let gate = gate.clone(); + let worker_expired = expired.clone(); + move || { + bounded_shutdown_fanout_with_clock( + &[(); SHUTDOWN_FANOUT_WORKERS + 1], + deadline, + || { + if worker_expired.load(Ordering::Acquire) { + deadline + } else { + deadline - Duration::from_millis(1) + } + }, + |_, _| { + started_tx.send(()).unwrap(); + let (released, wake) = &*gate; + let released = wake + .wait_while(released.lock().unwrap(), |released| !*released) + .unwrap(); + drop(released); + }, + ); + } }); + + for _ in 0..SHUTDOWN_FANOUT_WORKERS { + started_rx.recv_timeout(Duration::from_millis(200)).unwrap(); + } + expired.store(true, Ordering::Release); + let (released, wake) = &*gate; + *released.lock().unwrap() = true; + wake.notify_all(); + worker.join().unwrap(); + + assert_eq!(started_rx.try_iter().count(), 0); } #[test] - fn splitting_a_collapsed_stack_member_expands_the_target_side() { + fn surface_creation_fence_has_a_bounded_wait() { + let gate = SurfaceCreationGate::default(); + let creation = gate.begin().unwrap(); + let started = Instant::now(); + + assert!(!gate.stop_and_wait_until(started + Duration::from_millis(25))); + + drop(creation); + assert!(started.elapsed() < Duration::from_millis(250)); + } + + #[test] + fn server_shutdown_waits_for_in_flight_surface_creation() { let mux = test_mux(); - let first = mux.new_workspace(None, None).unwrap(); - let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); - let mut active = first_pane; - for _ in 1..13 { - let surface = mux.new_pane(active, None).unwrap(); - active = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - } - let target = mux.with_state(|state| { - state.workspaces[0].screens[0].zellij_auto_layout.as_ref().unwrap()[1] - }); + let workspace = mux.create_empty_workspace(Some("race".into()), None, None).unwrap(); + let (creation_reached_tx, creation_reached_rx) = std::sync::mpsc::sync_channel(1); + let (release_creation_tx, release_creation_rx) = std::sync::mpsc::sync_channel(1); + let release_creation_rx = Arc::new(Mutex::new(release_creation_rx)); + *mux.terminal_create_after_materialization_lock.lock().unwrap() = Some(Arc::new({ + move || { + creation_reached_tx.send(()).unwrap(); + release_creation_rx.lock().unwrap().recv().unwrap(); + } + })); - mux.split(target, SplitDir::Right, None).unwrap(); - mux.with_state(|state| { - let screen = &state.workspaces[0].screens[0]; - assert!(matches!( - &screen.root, - Node::Split { b, .. } - if matches!( - b.as_ref(), - Node::Split { a, .. } - if matches!(a.as_ref(), Node::Stack { expanded, .. } if *expanded == target) - ) - )); + let create = std::thread::spawn({ + let mux = mux.clone(); + move || { + mux.create_terminal_surface_in_workspace( + workspace.workspace, + None, + None, + None, + Some((80, 24)), + ) + .unwrap() + .0 + .id + } + }); + creation_reached_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let (shutdown_done_tx, shutdown_done_rx) = std::sync::mpsc::sync_channel(1); + let shutdown = std::thread::spawn({ + let mux = mux.clone(); + move || { + shutdown_done_tx.send(mux.close_all_surfaces_for_shutdown().unwrap()).unwrap(); + } }); + assert!(shutdown_done_rx.recv_timeout(Duration::from_millis(100)).is_err()); + + release_creation_tx.send(()).unwrap(); + let created = create.join().unwrap(); + assert_eq!(shutdown_done_rx.recv_timeout(Duration::from_secs(1)).unwrap(), 1); + shutdown.join().unwrap(); + assert!(mux.surface(created).is_none()); + assert_eq!( + mux.new_tab(None, None, None).unwrap_err().to_string(), + "server is shutting down" + ); + *mux.terminal_create_after_materialization_lock.lock().unwrap() = None; } + #[cfg(unix)] #[test] - fn structural_test_mux_can_create_many_surfaces_without_ptys() { + fn server_shutdown_keeps_topology_when_terminal_batch_commit_fails() { let mux = test_mux(); - let first = mux.new_workspace(None, Some((120, 40))).unwrap(); - let pane = mux.with_state(|s| s.pane_of(first.id).unwrap()); + let workspace = mux.create_empty_workspace(Some("durable".into()), None, None).unwrap(); + let terminal_id = "00000000000040008000000000000021"; + let incarnation = "10000000000040008000000000000021"; + let surface = + mux.seed_running_terminal_for_test(terminal_id, incarnation, &workspace.key).unwrap(); + mux.set_terminal_close_failure_for_test(true).unwrap(); - for _ in 0..450 { - mux.new_tab(Some(pane), None, None).unwrap(); + let error = mux.close_all_surfaces_for_shutdown().unwrap_err(); + + assert!(format!("{error:#}").contains("forced terminal close failure")); + assert!(!mux.shutdown_requested()); + assert!(!mux.daemon_shutdown_requested()); + assert!(mux.surface(surface).is_some()); + assert!(mux.with_state(|state| state.pane_of(surface).is_some())); + assert_eq!( + mux.shutdown_cleanup_health(), + ShutdownCleanupHealth { pending: 1, retrying: false, degraded: true } + ); + mux.set_terminal_close_failure_for_test(false).unwrap(); + assert_eq!(mux.close_all_surfaces_for_shutdown().unwrap(), 1); + assert_eq!( + mux.shutdown_cleanup_health(), + ShutdownCleanupHealth { pending: 0, retrying: false, degraded: false } + ); + assert!(!mux.shutdown_requested()); + assert!(!mux.daemon_shutdown_requested()); + assert!(mux.surface(surface).is_none()); + } + + #[cfg(unix)] + #[test] + fn server_stop_persists_a_restartable_empty_topology() { + const TERMINAL: &str = "00000000000040008000000000000022"; + const INCARNATION: &str = "10000000000040008000000000000022"; + let root = std::env::temp_dir() + .join(format!("cmux-server-stop-restart-{}", crate::workspace_registry::new_uuid_v4())); + let session = "server-stop-restart"; + { + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let workspace = mux.create_empty_workspace(Some("durable".into()), None, None).unwrap(); + mux.seed_running_terminal_for_test(TERMINAL, INCARNATION, &workspace.key).unwrap(); + + assert_eq!(mux.close_all_surfaces_for_shutdown().unwrap(), 1); } - assert_eq!(mux.surface_count(), 451); - mux.with_state(|s| { - let pane = &s.panes[&pane]; - assert_eq!(pane.tabs.len(), 451); - for surface in pane.tabs.iter().filter_map(|id| s.surfaces.get(id)) { - assert_eq!(surface.kind(), SurfaceKind::Pty); - assert_eq!(surface.size(), (120, 40)); - assert!(!surface.is_dead()); - } + let reopened = Mux::open_persistent(session, SurfaceOptions::default(), &root) + .expect("server stop must leave a restartable durable session"); + reopened.with_state(|state| { + assert_eq!(state.workspaces.len(), 1); + assert!(state.workspaces[0].screens.is_empty()); + assert!(state.panes.is_empty()); + assert!(state.surfaces.is_empty()); }); + reopened.shutdown().unwrap(); + drop(reopened); + std::fs::remove_dir_all(root).unwrap(); } #[test] @@ -23596,7 +28750,7 @@ mod tests { }) .collect::>(); assert!(layouts.is_empty()); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -23993,26 +29147,55 @@ mod tests { let mux = test_mux(); let initial = mux.create_empty_workspace(None, None, None).unwrap(); let events = mux.subscribe(); - let close_ready = Arc::new(std::sync::Barrier::new(2)); - let resume_close = Arc::new(std::sync::Barrier::new(2)); + let final_failure_timeout = crate::test_timeout(Duration::from_secs(2)); + let (close_reached_tx, close_reached_rx) = std::sync::mpsc::sync_channel(1); + let (release_close_tx, release_close_rx) = std::sync::mpsc::sync_channel(1); + let release_close_rx = Arc::new(Mutex::new(release_close_rx)); *mux.workspace_close_before_empty_check.lock().unwrap() = Some(Arc::new({ - let close_ready = close_ready.clone(); - let resume_close = resume_close.clone(); + let release_close_rx = Arc::clone(&release_close_rx); move || { - close_ready.wait(); - resume_close.wait(); + close_reached_tx.send(()).unwrap(); + release_close_rx + .lock() + .unwrap() + .recv_timeout(final_failure_timeout) + .expect("close owner was not released before the final failure deadline"); } })); + let (close_done_tx, close_done_rx) = std::sync::mpsc::sync_channel(1); let close_mux = mux.clone(); let close = std::thread::spawn(move || { - close_mux.close_workspace_at_revision(initial.workspace, Some(1)).unwrap() + close_done_tx + .send(close_mux.close_workspace_at_revision(initial.workspace, Some(1))) + .unwrap(); }); - close_ready.wait(); - let replacement = mux.create_empty_workspace(None, None, Some(2)).unwrap(); + close_reached_rx.recv_timeout(final_failure_timeout).expect( + "close did not reach the empty-workspace check before the final failure deadline", + ); + + let (replacement_done_tx, replacement_done_rx) = std::sync::mpsc::sync_channel(1); + let replacement_mux = mux.clone(); + let replacement_owner = std::thread::spawn(move || { + replacement_done_tx + .send(replacement_mux.create_empty_workspace(None, None, Some(2))) + .unwrap(); + }); + let replacement = replacement_done_rx + .recv_timeout(final_failure_timeout) + .expect("replacement creation did not finish before the final failure deadline") + .unwrap(); *mux.workspace_close_before_empty_check.lock().unwrap() = None; - resume_close.wait(); - assert_eq!(close.join().unwrap(), Some(2)); + release_close_tx.send(()).unwrap(); + assert_eq!( + close_done_rx + .recv_timeout(final_failure_timeout) + .expect("close did not finish after release before the final failure deadline") + .unwrap(), + Some(2) + ); + close.join().unwrap(); + replacement_owner.join().unwrap(); let emitted = events.try_iter().collect::>(); assert!(emitted.iter().any(|event| matches!( @@ -24213,7 +29396,7 @@ mod tests { .unwrap(), Some(2) ); - mux.shutdown(); + mux.shutdown().unwrap(); } let recovered = Mux::open_persistent_provider_managed( @@ -24231,7 +29414,7 @@ mod tests { }); let error = recovered.close_workspace_at_revision(1, None).unwrap_err(); assert!(error.to_string().contains("provider-managed workspace directly")); - recovered.shutdown(); + recovered.shutdown().unwrap(); drop(recovered); std::fs::remove_dir_all(root).unwrap(); } @@ -24300,9 +29483,184 @@ mod tests { assert!(exit["exited_at"].as_str().is_some()); assert_eq!(exit["revision"], "1"); assert_eq!(resolved.terminal_revision, 2); - mux.shutdown(); + mux.shutdown().unwrap(); + drop(mux); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn restart_waits_for_inflight_terminal_host_publication_before_marking_missing() { + const TERMINAL: &str = "00000000000040008000000000000041"; + const SESSION: &str = "recover-publishing-terminal"; + let root = std::env::temp_dir().join(format!( + "cmux-mux-terminal-publication-{}", + crate::workspace_registry::new_uuid_v4() + )); + { + let mut registry = WorkspaceRegistry::open(&root, SESSION).unwrap(); + registry + .commit( + &WorkspaceMutation::new("workspace", "test").unwrap(), + &serde_json::json!({"op":"create-workspace"}), + None, + Some(0), + "workspace-added", + "workspace-one", + &[RegistryWorkspace { + id: 1, + public_id: WorkspacePublicId::random().unwrap(), + key: "workspace-one".into(), + name: "One".into(), + group_key: SESSION.into(), + }], + &serde_json::json!({"workspace":1,"key":"workspace-one"}), + ) + .unwrap(); + commit_terminal_transition( + &mut registry, + "terminal-reserved", + "reserve-publishing-terminal", + &RegistryTerminal { + terminal_id: TERMINAL.into(), + workspace_key: "workspace-one".into(), + incarnation: None, + lifecycle: TerminalLifecycle::Launching, + launch_spec: serde_json::json!({"command_present":true}), + exit: None, + }, + ) + .unwrap(); + } + + let host_root = crate::terminal_host_runtime::terminal_host_root(&root, SESSION); + std::fs::create_dir_all(&host_root).unwrap(); + let record_path = host_root.join(format!("{TERMINAL}.json")); + let publication = + crate::PublicationGuard::acquire(&record_path, Instant::now() + Duration::from_secs(1)) + .unwrap(); + let options = + SurfaceOptions { terminal_host_root: Some(host_root), ..SurfaceOptions::default() }; + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + let open_root = root.clone(); + let worker = std::thread::spawn(move || { + result_tx.send(Mux::open_persistent(SESSION, options, &open_root)).unwrap(); + }); + + let early = result_rx.recv_timeout(Duration::from_millis(250)); + let waited_for_publication = + matches!(&early, Err(std::sync::mpsc::RecvTimeoutError::Timeout)); + drop(publication); + let mux = match early { + Ok(result) => result.unwrap(), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => result_rx + .recv_timeout(Duration::from_secs(2)) + .expect("restart did not resume after publication ownership ended") + .unwrap(), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + panic!("restart worker stopped before reporting its result") + } + }; + worker.join().unwrap(); + let terminal = mux.resolve_terminal(TERMINAL).unwrap().unwrap().terminal; + mux.shutdown().unwrap(); drop(mux); std::fs::remove_dir_all(root).unwrap(); + + assert!( + waited_for_publication, + "restart finalized terminal-host absence while publication ownership was live" + ); + assert_eq!(terminal.lifecycle, TerminalLifecycle::Exited); + } + + #[cfg(unix)] + #[test] + fn restart_rejects_unreadable_canonical_record_without_exiting_live_terminal() { + use std::os::unix::fs::PermissionsExt as _; + + const TERMINAL: &str = "00000000000040008000000000000021"; + const INCARNATION: &str = "10000000000040008000000000000021"; + const SESSION: &str = "recover-unreadable-terminal"; + let root = std::env::temp_dir().join(format!( + "cmux-mux-terminal-unreadable-{}", + crate::workspace_registry::new_uuid_v4() + )); + { + let mut registry = WorkspaceRegistry::open(&root, SESSION).unwrap(); + registry + .commit( + &WorkspaceMutation::new("workspace", "test").unwrap(), + &serde_json::json!({"op":"create-workspace"}), + None, + Some(0), + "workspace-added", + "workspace-one", + &[RegistryWorkspace { + id: 1, + public_id: WorkspacePublicId::random().unwrap(), + key: "workspace-one".into(), + name: "One".into(), + group_key: SESSION.into(), + }], + &serde_json::json!({"workspace":1,"key":"workspace-one"}), + ) + .unwrap(); + let mut terminal = RegistryTerminal { + terminal_id: TERMINAL.into(), + workspace_key: "workspace-one".into(), + incarnation: None, + lifecycle: TerminalLifecycle::Launching, + launch_spec: serde_json::json!({"command_present":true}), + exit: None, + }; + commit_terminal_transition( + &mut registry, + "terminal-reserved", + "reserve-unreadable-terminal", + &terminal, + ) + .unwrap(); + terminal.incarnation = Some(INCARNATION.into()); + terminal.lifecycle = TerminalLifecycle::Running; + commit_terminal_transition( + &mut registry, + "terminal-running", + "run-unreadable-terminal", + &terminal, + ) + .unwrap(); + } + + let host_root = crate::terminal_host_runtime::terminal_host_root(&root, SESSION); + std::fs::create_dir_all(&host_root).unwrap(); + let record_path = host_root.join(format!("{TERMINAL}.json")); + std::fs::write(&record_path, b"{").unwrap(); + let mut permissions = std::fs::metadata(&record_path).unwrap().permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(&record_path, permissions).unwrap(); + let options = + SurfaceOptions { terminal_host_root: Some(host_root), ..SurfaceOptions::default() }; + + let startup_rejected = match Mux::open_persistent(SESSION, options, &root) { + Ok(mux) => { + drop(mux); + false + } + Err(_) => true, + }; + let registry = WorkspaceRegistry::open(&root, SESSION).unwrap(); + let terminal = registry.terminal_record(TERMINAL).unwrap().unwrap(); + drop(registry); + std::fs::remove_dir_all(root).unwrap(); + + assert!( + startup_rejected, + "restart treated an unreadable canonical record as a missing host" + ); + assert_eq!(terminal.lifecycle, TerminalLifecycle::Running); + assert_eq!(terminal.incarnation.as_deref(), Some(INCARNATION)); + assert_eq!(terminal.exit, None); } #[cfg(unix)] @@ -24344,8 +29702,9 @@ mod tests { std::thread::yield_now(); } + let mut owner_reservation = mux.reserve_surface_owner().unwrap(); let error = mux - .finish_terminal_adoption(TERMINAL, INCARNATION, surface) + .finish_terminal_adoption(TERMINAL, INCARNATION, surface, &mut owner_reservation) .expect_err("dead adoption must fail after persisting its exit"); assert!(error.to_string().contains("exited during adoption")); @@ -24554,7 +29913,7 @@ mod tests { && change["resource"] == "terminal" && change["id"] == terminal_public_id.as_str() })); - mux.shutdown(); + let _ = mux.shutdown(); drop(mux); let reopened = Mux::open_persistent(session, options, &root).unwrap(); @@ -24563,7 +29922,7 @@ mod tests { waited ); assert_eq!(reopened.resource_events_after(1).unwrap().batches.len(), 1); - reopened.shutdown(); + let _ = reopened.shutdown(); drop(reopened); std::fs::remove_dir_all(root).unwrap(); } @@ -24646,7 +30005,7 @@ mod tests { let closed = mux.resolve_terminal(TERMINAL).unwrap().unwrap(); assert_eq!(closed.surface, None); assert_eq!(closed.terminal.lifecycle, TerminalLifecycle::Tombstoned); - mux.shutdown(); + mux.shutdown().unwrap(); drop(mux); let reopened = Mux::open_persistent("recover-exited", options, &root).unwrap(); @@ -24654,11 +30013,96 @@ mod tests { assert_eq!(tombstoned.surface, None); assert_eq!(tombstoned.terminal.lifecycle, TerminalLifecycle::Tombstoned); assert_eq!(tombstoned.terminal.exit.unwrap()["outcome"]["reason"], "persisted-exit"); - reopened.shutdown(); + reopened.shutdown().unwrap(); drop(reopened); std::fs::remove_dir_all(root).unwrap(); } + #[cfg(unix)] + #[test] + fn detached_terminal_close_rejects_stale_cleanup_identity_before_commit() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + const TERMINAL: &str = "0000000000004000800000000000000d"; + const INCARNATION: &str = "1000000000004000800000000000000d"; + const OTHER_INCARNATION: &str = "1000000000004000800000000000000e"; + let root = std::env::temp_dir().join(format!( + "cmux-mux-detached-cleanup-{}", + crate::workspace_registry::new_uuid_v4() + )); + let host_root = crate::terminal_host_runtime::terminal_host_root(&root, "detached-cleanup"); + let options = SurfaceOptions { + terminal_host_root: Some(host_root.clone()), + ..SurfaceOptions::default() + }; + let mux = Mux::new_for_test("detached-cleanup", options); + let workspace = + mux.create_empty_workspace(Some("detached-cleanup".into()), None, None).unwrap(); + let surface = + mux.seed_running_terminal_for_test(TERMINAL, INCARNATION, &workspace.key).unwrap(); + let terminal = + mux.workspace_registry.lock().unwrap().terminal_resource_id(TERMINAL).unwrap().unwrap(); + assert!( + mux.persist_terminal_exit_for_test( + &terminal, + &TerminalExit { + outcome: crate::terminal_host_protocol::TerminalExitOutcome::Exit { code: 0 }, + exited_at_ms: 1, + }, + ) + .unwrap() + ); + mux.surface_exited(surface); + let detached = mux.resolve_terminal(TERMINAL).unwrap().unwrap(); + assert_eq!(detached.surface, None); + assert_eq!(detached.terminal.lifecycle, TerminalLifecycle::Exited); + + mux.terminal_adoption_coordinator.state.lock().unwrap().stopping = true; + std::fs::create_dir_all(&host_root).unwrap(); + let uid = std::fs::metadata(&host_root).unwrap().uid(); + let host_record = crate::terminal_host_runtime::TerminalHostRecord { + record_version: 1, + terminal_id: TERMINAL.into(), + incarnation: OTHER_INCARNATION.into(), + endpoint: format!("/tmp/cmux-th-{uid}/{TERMINAL}.sock"), + owner_token: "01".repeat(crate::terminal_host::CAPABILITY_TOKEN_LEN), + host_pid: 0, + host_start_nonce: String::new(), + workspace_key: workspace.key, + supports_set_defaults: false, + supports_terminate_only: false, + supports_terminate_ack: false, + supports_clear_history: false, + }; + let host_record_path = host_root.join(format!("{TERMINAL}.json")); + std::fs::write(&host_record_path, serde_json::to_vec(&host_record).unwrap()).unwrap(); + let mut permissions = std::fs::metadata(&host_record_path).unwrap().permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(&host_record_path, permissions).unwrap(); + + let error = mux + .close_terminal(TERMINAL, INCARNATION) + .expect_err("detached terminal close accepted a different host incarnation"); + assert!(format!("{error:#}").contains("terminal-host incarnation changed")); + assert_eq!( + mux.resolve_terminal(TERMINAL).unwrap().unwrap().terminal.lifecycle, + TerminalLifecycle::Exited, + "rejected host cleanup changed the detached terminal lifecycle" + ); + + std::fs::remove_file(host_record_path).unwrap(); + let closed = mux.close_terminal(TERMINAL, INCARNATION).unwrap(); + assert_eq!(closed.surface, None); + assert_eq!( + mux.resolve_terminal(TERMINAL).unwrap().unwrap().terminal.lifecycle, + TerminalLifecycle::Tombstoned + ); + mux.terminal_adoption_coordinator.state.lock().unwrap().stopping = false; + mux.shutdown().unwrap(); + drop(mux); + std::fs::remove_dir_all(root).unwrap(); + } + #[cfg(unix)] #[test] fn hosted_terminal_exit_removes_sole_surface_but_preserves_exact_wait_result() { @@ -24846,7 +30290,7 @@ mod tests { TerminalHostIdentity { terminal_id: TERMINAL.into(), incarnation: INCARNATION.into() }, ) .unwrap(); - insert_surface_checked(&mut mux.state.lock().unwrap(), surface.clone()).unwrap(); + insert_surface_checked(&mux, &mut mux.state.lock().unwrap(), surface.clone()).unwrap(); let (placement, canonical_workspace, changed, _) = mux.bind_running_terminal_to_canonical_workspace(&surface).unwrap(); assert!(changed); @@ -24916,7 +30360,7 @@ mod tests { #[cfg(unix)] #[test] - fn pending_topology_accepts_current_host_reconnect_without_advancing_lifecycle() { + fn pending_topology_accepts_reconnect_for_the_exact_live_host() { const TERMINAL: &str = "00000000000040008000000000000012"; const INCARNATION: &str = "10000000000040008000000000000012"; const PENDING_SURFACE: SurfaceId = 4242; @@ -24942,16 +30386,27 @@ mod tests { }, ) .unwrap(); + commit_terminal_lifecycle( + &mut registry, + "terminal-ready", + "terminal-ready", + TERMINAL, + TerminalLifecycle::Running, + Some(INCARNATION), + None, + ) + .unwrap(); } - let _pending = - mux.register_pending_terminal_host(PENDING_SURFACE, identity.clone()).unwrap(); + let surface = Surface::spawn_for_test_with_terminal_host_identity( + PENDING_SURFACE, + mux.surface_options.lock().unwrap().clone(), + Arc::downgrade(&mux), + identity.clone(), + ) + .unwrap(); + insert_surface_checked(&mux, &mut mux.state.lock().unwrap(), surface).unwrap(); assert!(mux.terminal_host_connection_lost(PENDING_SURFACE, &identity)); - assert!(mux.terminal_host_reconnected( - PENDING_SURFACE, - &identity, - KittyGraphicsLimits::disabled(), - )); assert_eq!( mux.workspace_registry .lock() @@ -24960,19 +30415,8 @@ mod tests { .unwrap() .unwrap() .lifecycle, - TerminalLifecycle::Launching + TerminalLifecycle::Adopting ); - - mux.transition_terminal_lifecycle( - "terminal-adopting", - "test-adoption", - TERMINAL, - TerminalLifecycle::Adopting, - Some(INCARNATION), - None, - ) - .unwrap(); - assert!(mux.terminal_host_connection_lost(PENDING_SURFACE, &identity)); assert!(mux.terminal_host_reconnected( PENDING_SURFACE, &identity, @@ -24986,8 +30430,9 @@ mod tests { .unwrap() .unwrap() .lifecycle, - TerminalLifecycle::Adopting + TerminalLifecycle::Running ); + mux.shutdown().unwrap(); } #[cfg(unix)] @@ -25627,7 +31072,7 @@ mod tests { assert_eq!(first_surface.spawn_cwd().as_deref(), Some("/tmp")); assert_eq!(second_surface.spawn_cwd().as_deref(), Some("/tmp")); mux.set_resource_terminal_reservation_hook_for_test(None); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -25723,7 +31168,7 @@ mod tests { assert!(mux.surface(surface.id).is_some()); *mux.terminal_create_after_workspace_reservation.lock().unwrap() = None; surface.kill(); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -25790,7 +31235,7 @@ mod tests { mux.set_resource_terminal_reservation_hook_for_test(None); initial.kill(); created.kill(); - mux.shutdown(); + mux.shutdown().unwrap(); } } @@ -25839,7 +31284,7 @@ mod tests { close.join().unwrap(); *mux.workspace_close_after_selector_resolution.lock().unwrap() = None; assert!(close_result.unwrap_err().to_string().contains("unknown workspace key")); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -25900,7 +31345,7 @@ mod tests { assert!(!mux.close_workspace(managed.workspace)); *mux.workspace_close_after_selector_resolution.lock().unwrap() = None; - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -25978,7 +31423,7 @@ mod tests { assert_eq!(state.workspaces[0].screens.len(), 1); assert_eq!(state.workspace_revision, 1); }); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -26019,7 +31464,7 @@ mod tests { .expect_err("duplicate stable key must fail"); assert!(duplicate.to_string().contains("already exists")); mux.with_state(|state| assert_eq!(state.workspaces.len(), 1)); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -26035,7 +31480,7 @@ mod tests { assert_eq!(state.pane_of(surface.id), Some(state.workspaces[0].screens[0].active_pane)); assert_eq!(state.workspace_revision, 1); }); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -26064,7 +31509,7 @@ mod tests { let pane = workspace.screens[0].active_pane; assert_eq!(state.panes[&pane].tabs.len(), surfaces.len()); }); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -26146,7 +31591,7 @@ mod tests { assert_eq!(state.pane_of(browser.id), Some(pane)); assert_eq!(state.pane_of(terminal.surface), Some(pane)); }); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -26293,7 +31738,7 @@ mod tests { wait_for_text(&surface, "authority-test:after"); assert_eq!(surface.process_id(), process_id); assert!(!surface.is_dead()); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs index 965968c0c1b8..df88cce7d855 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs @@ -189,7 +189,7 @@ mod tests { assert_eq!(restored.notification_ledger[0].terminal_id.as_ref(), Some(&terminal)); assert_eq!(restored.notification_ledger[0].surface, Some(runtime.id)); assert!(restored.terminal_notifications[&terminal].unread); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/resource_content.rs b/cmux-tui/crates/cmux-tui-core/src/mux/resource_content.rs index 34d1505eb816..259014a7d839 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/resource_content.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/resource_content.rs @@ -727,22 +727,8 @@ impl Mux { let mut tab_order = Vec::with_capacity(pane.tabs.len()); for (position, surface_slot) in pane.tabs.iter().enumerate() { - // Restored terminal tabs exist in the topology before - // their host runtime is adopted. A structural commit - // must preserve those durable views instead of making - // unrelated host adoption a precondition. let surface = state.surfaces.get(surface_slot); - let identity = surface - .and_then(|surface| surface.resource_identity().cloned()) - .or_else(|| { - Some(TabResourceIdentity::new( - state.resource_indexes.tab_ids.get(surface_slot)?.clone(), - state.resource_indexes.content_ids.get(surface_slot)?.clone(), - )) - }) - .with_context(|| { - format!("pane surface {surface_slot} has no resource identity") - })?; + let identity = tab_resource_identity(state, *surface_slot)?; let before_tab = before_tabs.get(&identity.tab_id); live_tabs.insert(identity.tab_id.clone()); tab_order.push(identity.tab_id.clone()); @@ -1078,13 +1064,7 @@ fn ordered_terminal_tab_ids( let mut tabs = Vec::new(); for pane in state.panes.values() { for (position, surface_slot) in pane.tabs.iter().enumerate() { - let surface = state - .surfaces - .get(surface_slot) - .with_context(|| format!("pane references missing surface {surface_slot}"))?; - let identity = surface - .resource_identity() - .with_context(|| format!("pane surface {surface_slot} has no resource identity"))?; + let identity = tab_resource_identity(state, *surface_slot)?; if let ContentPublicId::Terminal(terminal_id) = &identity.content_id { tabs.push(( terminal_id.clone(), @@ -1098,6 +1078,32 @@ fn ordered_terminal_tab_ids( Ok(terminal_tab_ids_in_canonical_order(tabs)) } +/// A restored tab is durable before its terminal or browser runtime is +/// materialized. Structural projection must use the durable indexes during +/// that bounded startup state instead of making runtime adoption a +/// precondition for unrelated mutations. +fn tab_resource_identity( + state: &State, + surface_slot: crate::SurfaceId, +) -> anyhow::Result { + if let Some(identity) = + state.surfaces.get(&surface_slot).and_then(|surface| surface.resource_identity()) + { + return Ok(identity.clone()); + } + let tab_id = state + .resource_indexes + .tab_ids + .get(&surface_slot) + .cloned() + .with_context(|| format!("pane surface {surface_slot} has no public tab identity"))?; + let content_id = + state.resource_indexes.content_ids.get(&surface_slot).cloned().with_context(|| { + format!("pane surface {surface_slot} has no public content identity") + })?; + Ok(TabResourceIdentity::new(tab_id, content_id)) +} + fn registry_screen_from_live( state: &State, workspace_id: &WorkspacePublicId, @@ -1362,3 +1368,53 @@ fn public_tab_value(tab: &RegistryTab, focused: bool) -> Value { "content_id": tab.content_id.as_str(), }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Pane; + use crate::resource::{PublicSlotIndexes, TerminalPublicId}; + + #[test] + fn restored_terminal_tab_order_uses_durable_identity_before_adoption() { + let pane_slot = 7; + let surface_slot = 16; + let pane_id = PanePublicId::parse("pane_00000000000000000000000000000007").unwrap(); + let tab_id = TabPublicId::parse("tab_00000000000000000000000000000016").unwrap(); + let terminal_id = TerminalPublicId::parse("term_00000000000000000000000000000016").unwrap(); + let mut indexes = PublicSlotIndexes::default(); + indexes.tab_ids.insert(surface_slot, tab_id.clone()); + indexes.content_ids.insert(surface_slot, ContentPublicId::Terminal(terminal_id.clone())); + let state = State { + workspaces: Vec::new(), + workspace_index_by_id: HashMap::new(), + workspace_id_by_key: HashMap::new(), + workspace_revision: 0, + pane_revision: 0, + resource_revision: 0, + focus_sequence: 0, + active_workspace: 0, + panes: HashMap::from([( + pane_slot, + Pane { + id: pane_slot, + public_id: pane_id, + name: None, + tabs: vec![surface_slot], + active_tab: 0, + active_at: 0, + focused_at: 0, + }, + )]), + surfaces: HashMap::new(), + terminal_catalog: HashMap::new(), + terminal_catalog_by_runtime: HashMap::new(), + split_screens: HashMap::new(), + resource_indexes: indexes, + }; + + let ordered = ordered_terminal_tab_ids(&state).unwrap(); + + assert_eq!(ordered.get(&terminal_id), Some(&vec![tab_id])); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/resource_topology.rs b/cmux-tui/crates/cmux-tui-core/src/mux/resource_topology.rs index 32240bd0aab4..6dff76181da0 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/resource_topology.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/resource_topology.rs @@ -100,6 +100,8 @@ enum ResourceCloseTreePublication { struct CommittedResourceClose { commit: ResourcePatchCommit, effects: ResourceCloseEffects, + host_cleanup: TerminalHostRecords, + closed_workspace_terminals: Vec, } impl ResourceClosePlan { @@ -1951,7 +1953,9 @@ impl Mux { }; match preparation { ResourceEffectPreparation::Committed { outcome, revision } => match outcome { - ResourceEffectOutcome::Success(result) => { + ResourceEffectOutcome::Success(mut result) => { + self.terminate_workspace_hosts_from_receipt(&result)?; + remove_terminal_host_cleanup_receipt(&mut result); Ok(ResourcePatchCommit { revision, result, replayed: true }) } ResourceEffectOutcome::Failure(error) => Err(anyhow::Error::new(error)), @@ -2002,7 +2006,7 @@ impl Mux { }; drop(_creation_fence); drop(_creation_handoff); - return Ok(self.finish_resource_close(committed)); + return self.finish_resource_close(committed); } let result = match self.execute_resource_topology_effect(operation, &intent) { Ok(result) => result, @@ -2144,18 +2148,31 @@ impl Mux { .workspace .context("topology close target has no workspace")?; self.commit_resource_close_with( - operation, Some(workspace), idempotency_key, operation_name, fingerprint, - move |state| self.effect_slots_in_state(state, &path), + move |registry, state, notifications| { + let slots = self.effect_slots_in_state(state, &path)?; + let plan = self.resource_close_plan_locked( + operation, + slots, + registry, + state, + notifications, + )?; + Ok((slots, plan)) + }, ) } - pub(crate) fn commit_resource_surface_close_effect( + /// Commit a content close against its durable tab slot. A restored + /// terminal can occupy the slot before startup adoption attaches its + /// runtime surface, so identity and topology ownership are authoritative. + pub(crate) fn commit_resource_content_close_effect( &self, - surface: SurfaceId, + slot: SurfaceId, + content_id: &ContentPublicId, idempotency_key: &str, operation_name: &str, fingerprint: &Value, @@ -2163,34 +2180,78 @@ impl Mux { let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); let _creation_fence = self.resource_creation_execution.lock().unwrap(); let workspace = - self.surface_workspace(surface).context("content close target has no workspace")?; + self.surface_workspace(slot).context("content close target has no workspace")?; + let content_id = content_id.clone(); let committed = self.commit_resource_close_with( - ResourceOperation::TabClose, Some(workspace), idempotency_key, operation_name, fingerprint, - move |state| { - anyhow::ensure!(state.surfaces.contains_key(&surface), "content disappeared"); - let pane = state.pane_of(surface).context("content has no pane")?; + move |registry, state, notifications| { + anyhow::ensure!( + state.resource_indexes.content_ids.get(&slot) == Some(&content_id), + "content identity changed before close" + ); + let pane = state.pane_of(slot).context("content has no pane")?; let (workspace_index, screen_index) = state.screen_of(pane).context("content pane has no screen")?; - Ok(EffectSlots { + let slots = EffectSlots { workspace: Some(state.workspaces[workspace_index].id), screen: Some(state.workspaces[workspace_index].screens[screen_index].id), pane: Some(pane), - tab: Some(surface), - }) + tab: Some(slot), + }; + let plan = self.resource_close_plan_locked( + ResourceOperation::TabClose, + slots, + registry, + state, + notifications, + )?; + Ok((slots, plan)) }, )?; drop(_creation_fence); drop(_creation_handoff); - Ok(self.finish_resource_close(committed)) + self.finish_resource_close(committed) + } + + /// A terminal remains a session-owned resource after its last view and + /// runtime detach. Resolve an exact detached terminal through the durable + /// public-to-host relationship while preserving normal selector errors for + /// scoped, current, and name selectors. + pub(crate) fn resolve_resource_terminal_close_id( + &self, + selectors: &ResourceSelectors, + ) -> anyhow::Result { + let missing = match self.resolve_resource_path(ResourceTarget::Terminal, selectors) { + Ok(path) => { + return path.terminal.context("resolved terminal close omitted its public id"); + } + Err(error) if error.code == "selector.not_found" => error, + Err(error) => return Err(anyhow::Error::new(error)), + }; + if selectors.workspace.is_some() + || selectors.screen.is_some() + || selectors.pane.is_some() + || selectors.tab.is_some() + { + return Err(anyhow::Error::new(missing)); + } + let raw = selectors.terminal.as_deref().context("terminal close omitted its selector")?; + let public_id = match Selector::parse(raw).map_err(anyhow::Error::new)? { + Selector::Id(id) => TerminalPublicId::parse(id).map_err(anyhow::Error::new)?, + Selector::Current | Selector::Name(_) => return Err(anyhow::Error::new(missing)), + }; + if self.workspace_registry.lock().unwrap().live_terminal_host_id(&public_id)?.is_none() { + return Err(anyhow::Error::new(missing)); + } + Ok(public_id) } pub(crate) fn commit_resource_terminal_close_effect( &self, - surface: SurfaceId, + terminal_id: &TerminalPublicId, idempotency_key: &str, operation_name: &str, fingerprint: &Value, @@ -2198,22 +2259,29 @@ impl Mux { let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); let _creation_fence = self.resource_creation_execution.lock().unwrap(); let committed = self.commit_resource_close_with( - ResourceOperation::TerminalClose, None, idempotency_key, operation_name, fingerprint, - move |state| { - anyhow::ensure!( - state.terminal_runtime_by_id(surface).is_some(), - "terminal disappeared" - ); - Ok(EffectSlots { workspace: None, screen: None, pane: None, tab: Some(surface) }) + move |registry, state, _notifications| { + let host_id = registry + .live_terminal_host_id(terminal_id)? + .with_context(|| format!("terminal {terminal_id} is not live"))?; + let terminal = registry + .terminal_record(&host_id)? + .with_context(|| format!("terminal {terminal_id} has no durable host"))?; + let plan = self.resource_terminal_close_plan_locked( + &host_id, + terminal.incarnation.as_deref(), + terminal_id, + state, + )?; + Ok((EffectSlots { workspace: None, screen: None, pane: None, tab: None }, plan)) }, )?; drop(_creation_fence); drop(_creation_handoff); - Ok(self.finish_resource_close(committed)) + self.finish_resource_close(committed) } /// Route the legacy host close through the same projected topology owner @@ -2228,6 +2296,7 @@ impl Mux { expected_terminal_revision: Option, mutation: &WorkspaceMutation, ) -> anyhow::Result> { + let terminal_host_root = self.surface_options.lock().unwrap().terminal_host_root.clone(); let _creation_handoff = self.resource_creation_handoff.lock().unwrap(); let _creation_fence = self.resource_creation_execution.lock().unwrap(); let notifications = self.surface_notifications(); @@ -2300,6 +2369,18 @@ impl Mux { }, ) }; + let cleanup_identities = if plan.terminal_runtime.is_none() { + let terminal = registry.terminal_record(terminal_id)?.ok_or_else(|| { + terminal_close_state_error(format!("terminal close omitted host {terminal_id}")) + })?; + vec![(terminal_id.to_string(), terminal.incarnation)] + } else { + Vec::new() + }; + let host_cleanup = self.prepare_terminal_host_cleanup_at_root( + terminal_host_root.as_deref(), + &cleanup_identities, + )?; let mut projection = self.resource_effect_projection_locked(®istry, &mut plan.state, json!({}))?; if !projection.patch.changes.iter().any(|change| { @@ -2331,6 +2412,14 @@ impl Mux { "id":public_id, })); } + projection + .result + .as_object_mut() + .context("resource close result is not an object")? + .insert( + TERMINAL_HOST_CLEANUP_RECEIPT_FIELD.to_string(), + terminal_host_cleanup_receipt(&cleanup_identities), + ); #[cfg(test)] if let Some(hook) = self.resource_projection_before_commit.lock().unwrap().clone() { hook(); @@ -2394,7 +2483,12 @@ impl Mux { drop(registry); drop(_creation_fence); drop(_creation_handoff); - self.finish_resource_close(CommittedResourceClose { commit: resource, effects }); + self.finish_resource_close(CommittedResourceClose { + commit: resource, + effects, + host_cleanup, + closed_workspace_terminals: Vec::new(), + })?; Ok(Some(TerminalCloseResult { surface: target, terminal_id: terminal_id.to_string(), @@ -2594,32 +2688,75 @@ impl Mux { Ok(true) } - #[allow(clippy::too_many_arguments)] fn commit_resource_close_with( &self, - operation: ResourceOperation, workspace: Option, idempotency_key: &str, operation_name: &str, fingerprint: &Value, - resolve_slots: impl FnOnce(&State) -> anyhow::Result, + resolve_plan: impl FnOnce( + &WorkspaceRegistry, + &State, + &HashMap, + ) -> anyhow::Result<(EffectSlots, ResourceClosePlan)>, ) -> anyhow::Result { + let terminal_host_root = self.surface_options.lock().unwrap().terminal_host_root.clone(); let lifecycle = workspace.map(|workspace| self.workspace_lifecycle(workspace)); let workspace_lifecycle = lifecycle.as_ref().map(|lifecycle| lifecycle.lock().unwrap()); let notifications = self.surface_notifications(); let mut registry = self.workspace_registry.lock().unwrap(); let mut state = self.state.lock().unwrap(); - let slots = resolve_slots(&state)?; + let (slots, mut plan) = resolve_plan(®istry, &state, ¬ifications)?; if let Some(workspace) = workspace { anyhow::ensure!( slots.workspace == Some(workspace), "topology close target changed workspaces before commit" ); } - let mut plan = - self.resource_close_plan_locked(operation, slots, ®istry, &state, ¬ifications)?; - let projection = + let closed_workspace_key = + plan.workspace_close.as_ref().map(|close| close.workspace_key.clone()); + let workspace_host_identities = if let Some(workspace_key) = closed_workspace_key.as_deref() + { + registry.terminal_host_identities_in_workspace(workspace_key)? + } else { + Vec::new() + }; + if let Some(workspace_close) = plan.workspace_close.as_mut() { + workspace_close + .legacy_result + .as_object_mut() + .expect("workspace close result is an object") + .insert( + TERMINAL_HOST_CLEANUP_RECEIPT_FIELD.to_string(), + terminal_host_cleanup_receipt(&workspace_host_identities), + ); + } + let closed_workspace_terminals = if closed_workspace_key.is_some() { + Self::terminal_public_ids_for_hosted(®istry, &workspace_host_identities)? + } else { + Vec::new() + }; + let cleanup_identities = if closed_workspace_key.is_some() { + workspace_host_identities + } else if plan.terminal_runtime.is_none() { + plan.terminal_batch.clone() + } else { + Vec::new() + }; + let host_cleanup = self.prepare_terminal_host_cleanup_at_root( + terminal_host_root.as_deref(), + &cleanup_identities, + )?; + let mut projection = self.resource_effect_projection_locked(®istry, &mut plan.state, json!({}))?; + projection + .result + .as_object_mut() + .context("resource close result is not an object")? + .insert( + TERMINAL_HOST_CLEANUP_RECEIPT_FIELD.to_string(), + terminal_host_cleanup_receipt(&cleanup_identities), + ); #[cfg(test)] if let Some(hook) = self.resource_projection_before_commit.lock().unwrap().clone() { hook(); @@ -2659,10 +2796,24 @@ impl Mux { } drop(registry); drop(workspace_lifecycle); - Ok(CommittedResourceClose { commit: close.resource, effects }) + Ok(CommittedResourceClose { + commit: close.resource, + effects, + host_cleanup, + closed_workspace_terminals, + }) } - fn finish_resource_close(&self, committed: CommittedResourceClose) -> ResourcePatchCommit { - let effects = committed.effects; + fn finish_resource_close( + &self, + committed: CommittedResourceClose, + ) -> anyhow::Result { + let CommittedResourceClose { + mut commit, + effects, + host_cleanup, + closed_workspace_terminals, + } = committed; + self.notify_terminal_exit_waiters(closed_workspace_terminals); if let Some(terminal_id) = effects.closed_terminal_public_id { self.notify_terminal_exit_waiters(Some(terminal_id)); } @@ -2680,8 +2831,9 @@ impl Mux { } if let Some(runtime) = effects.terminal_runtime { self.purge_terminal_runtime_side_tables(&runtime); - self.terminate_terminal_runtime(&runtime); + self.retire_surface_runtime(runtime); } + let host_termination = self.terminate_prepared_terminal_hosts(host_cleanup); match effects.tree_publication { ResourceCloseTreePublication::PendingDelta(delta) => { self.emit_tree_delta(delta, effects.selection_resync); @@ -2698,7 +2850,9 @@ impl Mux { self.emit(MuxEvent::LayoutChanged(screen)); } self.emit_empty_if_current(effects.empty_revision); - committed.commit + host_termination?; + remove_terminal_host_cleanup_receipt(&mut commit.result); + Ok(commit) } fn resource_close_plan_locked( @@ -2709,17 +2863,7 @@ impl Mux { state: &State, notifications: &HashMap, ) -> anyhow::Result { - let selection_before = active_tree_selection(state); - let mut projected = state.clone(); - let ResourceCloseInputs { - surface_ids, - mut delta, - changed_screens, - workspace_metadata, - terminal_runtime, - terminal_batch, - terminal_public_id, - } = match operation { + let inputs = match operation { ResourceOperation::WorkspaceClose => { let workspace = slots.workspace.context("workspace disappeared")?; let index = state.workspace_index(workspace).context("workspace disappeared")?; @@ -2823,16 +2967,65 @@ impl Mux { _ => anyhow::bail!("operation is not a topology close"), }; + self.resource_close_plan_from_inputs_locked(inputs, state) + } + + fn resource_terminal_close_plan_locked( + &self, + terminal_id: &str, + terminal_incarnation: Option<&str>, + public_id: &TerminalPublicId, + state: &State, + ) -> anyhow::Result { + let placements = + state.placements_of_content(&ContentPublicId::Terminal(public_id.clone())).to_vec(); + let changed_screens = unique_screen_ids( + placements.iter().filter_map(|surface| surface_screen_id(state, *surface)), + ); + self.resource_close_plan_from_inputs_locked( + ResourceCloseInputs { + surface_ids: placements, + changed_screens, + terminal_runtime: state.terminal_catalog.get(public_id).cloned(), + terminal_batch: vec![( + terminal_id.to_string(), + terminal_incarnation.map(str::to_owned), + )], + terminal_public_id: Some(public_id.clone()), + ..Default::default() + }, + state, + ) + } + + fn resource_close_plan_from_inputs_locked( + &self, + inputs: ResourceCloseInputs, + state: &State, + ) -> anyhow::Result { + let selection_before = active_tree_selection(state); + let mut projected = state.clone(); + let ResourceCloseInputs { + surface_ids, + mut delta, + changed_screens, + workspace_metadata, + terminal_runtime, + terminal_batch, + terminal_public_id, + } = inputs; + let mut removed = Vec::new(); let mut split_index_changed = false; if let Some(public_id) = &terminal_public_id { let (removed_runtime, terminal_views, changed) = remove_terminal_content_from_state(self, &mut projected, public_id); anyhow::ensure!( - removed_runtime - .as_ref() - .zip(terminal_runtime.as_ref()) - .is_some_and(|(removed, planned)| removed.shares_terminal_runtime(planned)), + match (removed_runtime.as_ref(), terminal_runtime.as_ref()) { + (Some(removed), Some(planned)) => removed.shares_terminal_runtime(planned), + (None, None) => true, + _ => false, + }, "terminal close lost its catalog runtime" ); removed = terminal_views; @@ -2956,6 +3149,10 @@ impl Mux { )?, Some(preparation) => preparation, None => { + // Reject known exhaustion before persisting a new external-effect + // intent. The spawn path still reserves atomically, so a later + // concurrent admission remains bounded and recoverable. + self.ensure_surface_owner_capacity()?; let mut state = self.state.lock().unwrap(); let selectors = self.select_live_creation_selectors( operation, @@ -4227,6 +4424,13 @@ impl Mux { let surface = self.spawn_surface_in_workspace_reserved(&workspace_key, cwd, size, None, reservation)?; #[cfg(test)] + if split_direction.is_none() + && viewport_width.is_none() + && let Some(hook) = self.new_pane_after_spawn.lock().unwrap().clone() + { + hook(surface.clone()); + } + #[cfg(test)] if viewport_width.is_some() && let Some(hook) = self.viewport_split_after_spawn.lock().unwrap().clone() { @@ -6200,7 +6404,7 @@ mod creation_recovery_tests { ) .unwrap_err(); assert_eq!(error.to_string(), "resource revision conflict: expected 0, current 1"); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -6275,7 +6479,7 @@ mod creation_recovery_tests { "recovery":"retry_new_idempotency_key", }) ); - mux.shutdown(); + let _ = mux.shutdown(); drop(mux); std::fs::remove_dir_all(root).unwrap(); } @@ -6330,7 +6534,7 @@ mod creation_recovery_tests { true, ) { Ok(mux) => { - mux.shutdown(); + let _ = mux.shutdown(); panic!("multiple interrupted creations unexpectedly started") } Err(error) => error, diff --git a/cmux-tui/crates/cmux-tui-core/src/platform.rs b/cmux-tui/crates/cmux-tui-core/src/platform.rs index 80d283bb9655..24d6162ea9a1 100644 --- a/cmux-tui/crates/cmux-tui-core/src/platform.rs +++ b/cmux-tui/crates/cmux-tui-core/src/platform.rs @@ -10,13 +10,18 @@ pub mod transport { use std::io::{self, Read, Write}; use std::net::Shutdown; use std::path::Path; - use std::time::Duration; + use std::time::{Duration, Instant}; pub trait Stream: Read + Write + Send + Sync { fn try_clone_box(&self) -> io::Result>; + fn read_timeout(&self) -> io::Result>; fn set_read_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_timeout(&self) -> io::Result>; fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; fn shutdown(&self, how: Shutdown) -> io::Result<()>; + fn peer_process_id(&self) -> io::Result> { + Ok(None) + } } pub struct Listener { @@ -31,6 +36,18 @@ pub mod transport { imp::connect(path) } + pub fn connect_until(path: &Path, deadline: Instant) -> io::Result> { + imp::connect_until(path, deadline) + } + + #[cfg(unix)] + pub(crate) fn connect_unix_until( + path: &Path, + deadline: Instant, + ) -> io::Result { + imp::connect_unix_until(path, deadline) + } + impl Listener { pub fn accept(&self) -> io::Result> { self.inner.accept() @@ -40,40 +57,317 @@ pub mod transport { #[cfg(unix)] mod imp { use std::io; + use std::mem::{offset_of, size_of}; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::os::unix::ffi::OsStrExt; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::Path; - use std::time::Duration; + use std::time::{Duration, Instant}; use super::Stream; + #[cfg(test)] + type SocketCreatedHook = std::sync::Arc; + #[cfg(test)] + static SOCKET_CREATED_HOOK: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + pub(super) struct Listener { inner: UnixListener, } + #[cfg(test)] + pub(super) fn set_socket_created_hook(hook: Option) { + *SOCKET_CREATED_HOOK + .get_or_init(Default::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = hook; + } + + #[cfg(test)] + fn socket_created_hook() -> Option { + SOCKET_CREATED_HOOK + .get_or_init(Default::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + #[cfg(target_os = "linux")] + fn create_close_on_exec_socket(_deadline: Instant) -> io::Result { + // SAFETY: socket has no pointer arguments and returns a new owned + // descriptor on success. SOCK_CLOEXEC sets the inheritance flag + // atomically with descriptor creation. + let descriptor = + unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) }; + if descriptor < 0 { + let error = io::Error::last_os_error(); + if matches!(error.raw_os_error(), Some(libc::EINVAL) | Some(libc::EPROTONOSUPPORT)) + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("atomic close-on-exec sockets are unavailable: {error}"), + )); + } + return Err(error); + } + // SAFETY: descriptor is a fresh successful socket result and this + // OwnedFd takes its sole ownership. + let descriptor = unsafe { OwnedFd::from_raw_fd(descriptor) }; + #[cfg(test)] + if let Some(hook) = socket_created_hook() { + hook(descriptor.as_raw_fd()); + } + Ok(descriptor) + } + + #[cfg(not(target_os = "linux"))] + fn create_close_on_exec_socket(deadline: Instant) -> io::Result { + let _process_creation = + cmux_tui_process::ProcessCreationGuard::acquire_until(deadline)?; + // Unix platforms without the Linux atomic socket flag share this + // barrier with every cmux-tui process launch, so no child can start + // until fcntl marks the fresh descriptor close-on-exec. + // SAFETY: socket has no pointer arguments and returns a new owned + // descriptor on success. + let descriptor = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: descriptor is a fresh successful socket result and this + // OwnedFd takes its sole ownership. + let descriptor = unsafe { OwnedFd::from_raw_fd(descriptor) }; + #[cfg(test)] + if let Some(hook) = socket_created_hook() { + hook(descriptor.as_raw_fd()); + } + // SAFETY: F_GETFD only reads flags from this valid descriptor. + let descriptor_flags = unsafe { libc::fcntl(descriptor.as_raw_fd(), libc::F_GETFD) }; + if descriptor_flags < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: F_SETFD updates flags while the process barrier excludes + // process creation from this setup window. + if unsafe { + libc::fcntl( + descriptor.as_raw_fd(), + libc::F_SETFD, + descriptor_flags | libc::FD_CLOEXEC, + ) + } < 0 + { + return Err(io::Error::last_os_error()); + } + Ok(descriptor) + } + pub(super) fn listen(path: &Path) -> io::Result { - UnixListener::bind(path).map(|inner| Listener { inner }) + cmux_tui_process::unix::bind_listener(path).map(|inner| Listener { inner }) } pub(super) fn connect(path: &Path) -> io::Result> { - Ok(Box::new(UnixStream::connect(path)?)) + Ok(Box::new(cmux_tui_process::unix::connect_stream(path)?)) + } + + pub(super) fn connect_until(path: &Path, deadline: Instant) -> io::Result> { + connect_unix_until(path, deadline).map(|stream| Box::new(stream) as Box) + } + + pub(super) fn connect_unix_until(path: &Path, deadline: Instant) -> io::Result { + ensure_connect_time_remaining(deadline)?; + let (address, address_len) = unix_socket_address(path)?; + let descriptor = create_close_on_exec_socket(deadline)?; + let stream = UnixStream::from(descriptor); + stream.set_nonblocking(true)?; + loop { + ensure_connect_time_remaining(deadline)?; + // SAFETY: address is an initialized sockaddr_un with its + // exact kernel-visible length, and stream owns a valid + // AF_UNIX socket. + let connected = unsafe { + libc::connect( + stream.as_raw_fd(), + (&raw const address).cast::(), + address_len, + ) + }; + if connected == 0 { + break; + } + let error = io::Error::last_os_error(); + let code = error.raw_os_error(); + if retry_connect_after_would_block(code, deadline)? { + continue; + } + let pending = [ + Some(libc::EAGAIN), + Some(libc::EINPROGRESS), + Some(libc::EWOULDBLOCK), + Some(libc::EALREADY), + Some(libc::EINTR), + ] + .contains(&code); + if !pending { + return Err(error); + } + wait_for_connect(&stream, deadline)?; + break; + } + stream.set_nonblocking(false)?; + Ok(stream) + } + + fn retry_connect_after_would_block( + code: Option, + deadline: Instant, + ) -> io::Result { + #[cfg(target_os = "linux")] + if code == Some(libc::EAGAIN) || code == Some(libc::EWOULDBLOCK) { + // Linux leaves a nonblocking AF_UNIX socket unconnected when + // the listener queue is full. The socket still polls writable + // with SO_ERROR zero, so retry connect instead of treating + // writability as completion. + let remaining = ensure_connect_time_remaining(deadline)?; + std::thread::sleep(remaining.min(Duration::from_millis(5))); + return Ok(true); + } + let _ = (code, deadline); + Ok(false) + } + + fn ensure_connect_time_remaining(deadline: Instant) -> io::Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "transport connection exceeded its deadline", + ) + }) + } + + fn wait_for_connect(stream: &UnixStream, deadline: Instant) -> io::Result<()> { + loop { + let remaining = ensure_connect_time_remaining(deadline)?; + let timeout_ms = remaining.as_millis().max(1).min(i32::MAX as u128) as i32; + let mut descriptor = + libc::pollfd { fd: stream.as_raw_fd(), events: libc::POLLOUT, revents: 0 }; + // SAFETY: descriptor points to one initialized pollfd for the + // duration of this call. + let result = unsafe { libc::poll(&raw mut descriptor, 1, timeout_ms) }; + if result == 0 { + continue; + } + if result < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } + if descriptor.revents & libc::POLLNVAL != 0 { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "transport connection socket became invalid", + )); + } + let mut socket_error = 0; + let mut socket_error_len = + libc::socklen_t::try_from(size_of::()).unwrap(); + // SAFETY: socket_error and its length describe a writable + // c_int, and stream owns a valid socket descriptor. + if unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_ERROR, + (&raw mut socket_error).cast(), + &raw mut socket_error_len, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + return if socket_error == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(socket_error)) + }; + } + } + + pub(super) fn unix_socket_address( + path: &Path, + ) -> io::Result<(libc::sockaddr_un, libc::socklen_t)> { + const SUN_PATH_CAPACITY: usize = + size_of::() - offset_of!(libc::sockaddr_un, sun_path); + let path = path.as_os_str().as_bytes(); + if path.is_empty() || path.len() >= SUN_PATH_CAPACITY || path.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid transport Unix socket path", + )); + } + // SAFETY: all-zero is a valid starting representation for + // sockaddr_un; family, path, and platform length are set below. + let mut address = unsafe { std::mem::zeroed::() }; + address.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (destination, source) in address.sun_path.iter_mut().zip(path) { + *destination = *source as libc::c_char; + } + let address_len = offset_of!(libc::sockaddr_un, sun_path) + path.len() + 1; + #[cfg(any( + target_os = "dragonfly", + target_os = "freebsd", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ))] + { + address.sun_len = u8::try_from(address_len).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "transport Unix socket path is too long", + ) + })?; + } + Ok(( + address, + libc::socklen_t::try_from(address_len).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "transport Unix socket path is too long", + ) + })?, + )) } impl Listener { pub(super) fn accept(&self) -> io::Result> { - let (stream, _) = self.inner.accept()?; + let (stream, _) = cmux_tui_process::unix::accept_stream(&self.inner)?; Ok(Box::new(stream)) } } impl Stream for UnixStream { fn try_clone_box(&self) -> io::Result> { - Ok(Box::new(self.try_clone()?)) + Ok(Box::new(cmux_tui_process::unix::clone_stream(self)?)) + } + + fn read_timeout(&self) -> io::Result> { + UnixStream::read_timeout(self) } fn set_read_timeout(&self, timeout: Option) -> io::Result<()> { UnixStream::set_read_timeout(self, timeout) } + fn write_timeout(&self) -> io::Result> { + UnixStream::write_timeout(self) + } + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { UnixStream::set_write_timeout(self, timeout) } @@ -81,17 +375,92 @@ pub mod transport { fn shutdown(&self, how: std::net::Shutdown) -> io::Result<()> { UnixStream::shutdown(self, how) } + + fn peer_process_id(&self) -> io::Result> { + peer_process_id(self) + } + } + + #[cfg(target_os = "macos")] + fn peer_process_id(stream: &UnixStream) -> io::Result> { + use std::mem::size_of; + use std::os::fd::AsRawFd; + + let mut pid: libc::pid_t = 0; + let mut length = size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + (&raw mut pid).cast(), + &raw mut length, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + if length as usize != size_of::() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid peer process id")); + } + u32::try_from(pid) + .map(Some) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid peer process id")) + } + + #[cfg(target_os = "linux")] + fn peer_process_id(stream: &UnixStream) -> io::Result> { + use std::mem::{size_of, zeroed}; + use std::os::fd::AsRawFd; + + let mut credentials = unsafe { zeroed::() }; + let mut length = size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&raw mut credentials).cast(), + &raw mut length, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + if length as usize != size_of::() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid peer credentials")); + } + u32::try_from(credentials.pid) + .map(Some) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid peer process id")) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + fn peer_process_id(_stream: &UnixStream) -> io::Result> { + Ok(None) } } #[cfg(windows)] mod imp { use std::io; + use std::mem::{offset_of, size_of}; + use std::os::windows::io::{ + AsRawSocket, FromRawSocket, IntoRawSocket, OwnedSocket, RawSocket, + }; use std::path::Path; - use std::time::Duration; + use std::sync::OnceLock; + use std::time::{Duration, Instant}; use super::Stream; use uds_windows::{UnixListener, UnixStream}; + use windows_sys::Win32::Networking::WinSock::{ + AF_UNIX, FIONBIO, INVALID_SOCKET, POLLNVAL, POLLWRNORM, SO_ERROR, SOCK_STREAM, + SOCKADDR, SOCKADDR_UN, SOCKET, SOCKET_ERROR, SOL_SOCKET, WSA_FLAG_NO_HANDLE_INHERIT, + WSA_FLAG_OVERLAPPED, WSADATA, WSAEALREADY, WSAEINPROGRESS, WSAEINTR, WSAEWOULDBLOCK, + WSAGetLastError, WSAPOLLFD, WSAPoll, WSASocketW, WSAStartup, + connect as winsock_connect, getsockopt, ioctlsocket, + }; pub(super) struct Listener { inner: UnixListener, @@ -105,6 +474,185 @@ pub mod transport { Ok(Box::new(UnixStream::connect(path)?)) } + pub(super) fn connect_until(path: &Path, deadline: Instant) -> io::Result> { + ensure_connect_time_remaining(deadline)?; + initialize_winsock()?; + let (address, address_len) = unix_socket_address(path)?; + // SAFETY: WSASocketW receives no borrowed protocol descriptor and + // returns a new socket handle on success. + let socket = unsafe { + WSASocketW( + AF_UNIX as i32, + SOCK_STREAM, + 0, + std::ptr::null(), + 0, + WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT, + ) + }; + if socket == INVALID_SOCKET { + return Err(last_winsock_error()); + } + // SAFETY: socket is a fresh successful WSASocketW result and this + // OwnedSocket takes its sole ownership. + let socket = unsafe { OwnedSocket::from_raw_socket(socket as RawSocket) }; + let socket_handle = winsock_handle(&socket)?; + let mut nonblocking = 1; + // SAFETY: socket is valid and nonblocking points to a writable + // u32 for the duration of the call. + if unsafe { ioctlsocket(socket_handle, FIONBIO, &raw mut nonblocking) } == SOCKET_ERROR + { + return Err(last_winsock_error()); + } + // SAFETY: address is an initialized SOCKADDR_UN with its exact + // Winsock-visible length. + let connected = unsafe { + winsock_connect(socket_handle, (&raw const address).cast::(), address_len) + }; + if connected == SOCKET_ERROR { + let error = last_winsock_error(); + let code = error.raw_os_error(); + let pending = + [Some(WSAEALREADY), Some(WSAEINPROGRESS), Some(WSAEINTR), Some(WSAEWOULDBLOCK)] + .contains(&code); + if !pending { + return Err(error); + } + wait_for_connect(socket_handle, deadline)?; + } + nonblocking = 0; + // SAFETY: socket remains valid and nonblocking points to a + // writable u32 for the duration of the call. + if unsafe { ioctlsocket(socket_handle, FIONBIO, &raw mut nonblocking) } == SOCKET_ERROR + { + return Err(last_winsock_error()); + } + let raw = socket.into_raw_socket(); + // SAFETY: ownership was transferred out of OwnedSocket exactly + // once and UnixStream accepts that same Winsock socket handle. + Ok(Box::new(unsafe { UnixStream::from_raw_socket(raw) })) + } + + fn winsock_handle(socket: &OwnedSocket) -> io::Result { + SOCKET::try_from(socket.as_raw_socket()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "transport socket handle does not fit the Winsock ABI", + ) + }) + } + + fn initialize_winsock() -> io::Result<()> { + static STARTUP: OnceLock = OnceLock::new(); + let result = *STARTUP.get_or_init(|| { + // SAFETY: data is writable and lives for the duration of + // WSAStartup. Version 2.2 is the Winsock API used below. + unsafe { + let mut data = std::mem::zeroed::(); + WSAStartup(0x0202, &raw mut data) + } + }); + if result == 0 { Ok(()) } else { Err(io::Error::from_raw_os_error(result)) } + } + + fn ensure_connect_time_remaining(deadline: Instant) -> io::Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "transport connection exceeded its deadline", + ) + }) + } + + fn wait_for_connect(socket: SOCKET, deadline: Instant) -> io::Result<()> { + loop { + let remaining = ensure_connect_time_remaining(deadline)?; + let timeout_ms = remaining.as_millis().max(1).min(i32::MAX as u128) as i32; + let mut descriptor = WSAPOLLFD { fd: socket, events: POLLWRNORM, revents: 0 }; + // SAFETY: descriptor points to one initialized WSAPOLLFD for + // the duration of this call. + let result = unsafe { WSAPoll(&raw mut descriptor, 1, timeout_ms) }; + if result == 0 { + continue; + } + if result == SOCKET_ERROR { + let error = last_winsock_error(); + if error.raw_os_error() == Some(WSAEINTR) { + continue; + } + return Err(error); + } + if descriptor.revents & POLLNVAL != 0 { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "transport connection socket became invalid", + )); + } + let mut socket_error = 0i32; + let mut socket_error_len = i32::try_from(size_of::()).unwrap(); + // SAFETY: socket_error and its length describe one writable + // i32, and socket remains a valid Winsock handle. + if unsafe { + getsockopt( + socket, + SOL_SOCKET, + SO_ERROR, + (&raw mut socket_error).cast(), + &raw mut socket_error_len, + ) + } == SOCKET_ERROR + { + return Err(last_winsock_error()); + } + return if socket_error == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(socket_error)) + }; + } + } + + fn unix_socket_address(path: &Path) -> io::Result<(SOCKADDR_UN, i32)> { + let path = path.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "transport Unix socket path is not valid UTF-8", + ) + })?; + let path = path.as_bytes(); + let capacity = size_of::() - offset_of!(SOCKADDR_UN, sun_path); + if path.is_empty() || path.len() >= capacity || path.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid transport Unix socket path", + )); + } + let mut address = SOCKADDR_UN::default(); + address.sun_family = AF_UNIX; + for (destination, source) in address.sun_path.iter_mut().zip(path) { + *destination = *source as i8; + } + let length = offset_of!(SOCKADDR_UN, sun_path) + path.len() + 1; + Ok(( + address, + i32::try_from(length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "transport Unix socket path is too long", + ) + })?, + )) + } + + fn last_winsock_error() -> io::Error { + // SAFETY: WSAGetLastError has no pointer arguments and reads the + // calling thread's Winsock error state. + io::Error::from_raw_os_error(unsafe { WSAGetLastError() }) + } + impl Listener { pub(super) fn accept(&self) -> io::Result> { let (stream, _) = self.inner.accept()?; @@ -117,10 +665,18 @@ pub mod transport { Ok(Box::new(self.try_clone()?)) } + fn read_timeout(&self) -> io::Result> { + UnixStream::read_timeout(self) + } + fn set_read_timeout(&self, timeout: Option) -> io::Result<()> { UnixStream::set_read_timeout(self, timeout) } + fn write_timeout(&self) -> io::Result> { + UnixStream::write_timeout(self) + } + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { UnixStream::set_write_timeout(self, timeout) } @@ -130,6 +686,418 @@ pub mod transport { } } } + + #[cfg(test)] + mod tests { + use super::*; + + #[cfg(unix)] + static SOCKET_CREATED_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[cfg(unix)] + struct SocketCreatedHookReset; + + #[cfg(unix)] + impl Drop for SocketCreatedHookReset { + fn drop(&mut self) { + imp::set_socket_created_hook(None); + } + } + + #[cfg(unix)] + fn socket_created_test_lock() -> std::sync::MutexGuard<'static, ()> { + SOCKET_CREATED_TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + #[test] + fn expired_connect_deadline_fails_before_socket_resolution() { + let error = connect_until( + Path::new("deadline-expired-before-address-resolution"), + Instant::now() - Duration::from_millis(1), + ) + .err() + .expect("expired transport connect unexpectedly succeeded"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + } + + #[cfg(unix)] + #[test] + fn deadline_connect_preserves_the_stream_contract() { + let path = std::env::temp_dir().join(format!( + "cmux-platform-connect-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + + let stream = connect_until(&path, Instant::now() + Duration::from_secs(1)).unwrap(); + let (_accepted, _) = listener.accept().unwrap(); + + drop(stream); + drop(listener); + std::fs::remove_file(path).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn deadline_connect_is_close_on_exec_at_socket_creation() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let _guard = socket_created_test_lock(); + let _hook_reset = SocketCreatedHookReset; + let path = std::env::temp_dir().join(format!( + "cmux-platform-cloexec-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let close_on_exec = Arc::new(AtomicBool::new(false)); + imp::set_socket_created_hook(Some(Arc::new({ + let close_on_exec = close_on_exec.clone(); + move |descriptor| { + // SAFETY: the hook receives the fresh live socket + // descriptor before connect_until performs any fallback. + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFD) }; + close_on_exec + .store(flags >= 0 && flags & libc::FD_CLOEXEC != 0, Ordering::Release); + } + }))); + + let stream = connect_until(&path, Instant::now() + Duration::from_secs(1)).unwrap(); + imp::set_socket_created_hook(None); + let (_accepted, _) = listener.accept().unwrap(); + + drop(stream); + drop(listener); + std::fs::remove_file(path).unwrap(); + assert!( + close_on_exec.load(Ordering::Acquire), + "deadline connector exposed an inheritable descriptor before setting close-on-exec" + ); + } + + #[cfg(all(unix, not(target_os = "linux")))] + #[test] + fn deadline_connect_confines_inheritable_descriptor_to_process_barrier() { + use std::os::fd::AsRawFd as _; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let _guard = socket_created_test_lock(); + let _hook_reset = SocketCreatedHookReset; + let path = std::path::PathBuf::from("/tmp").join(format!( + "cmux-cxfb-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let descriptor_was_inheritable = Arc::new(AtomicBool::new(false)); + imp::set_socket_created_hook(Some(Arc::new({ + let descriptor_was_inheritable = descriptor_was_inheritable.clone(); + move |descriptor| { + // SAFETY: the hook receives the fresh live socket before + // the process barrier is released. + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFD) }; + descriptor_was_inheritable + .store(flags >= 0 && flags & libc::FD_CLOEXEC == 0, Ordering::Release); + } + }))); + + let stream = + connect_unix_until(&path, Instant::now() + Duration::from_secs(1)).unwrap(); + imp::set_socket_created_hook(None); + let flags = unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFD) }; + let (_accepted, _) = listener.accept().unwrap(); + + drop(stream); + drop(listener); + std::fs::remove_file(path).unwrap(); + assert!( + descriptor_was_inheritable.load(Ordering::Acquire), + "test did not observe the non-atomic macOS descriptor setup window" + ); + assert!(flags >= 0 && flags & libc::FD_CLOEXEC != 0); + } + + #[cfg(target_os = "macos")] + #[test] + fn deadline_connect_excludes_concurrent_browser_process_creation() { + use std::os::unix::ffi::OsStrExt as _; + use std::os::unix::fs::PermissionsExt as _; + use std::sync::Arc; + use std::sync::mpsc; + + let _guard = socket_created_test_lock(); + let _hook_reset = SocketCreatedHookReset; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::path::PathBuf::from("/tmp") + .join(format!("cmux-cxspawn-{}-{nonce}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let socket_path = root.join("server.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + let marker_path = root.join("descriptor-state"); + let browser_path = root.join("browser"); + let profile_path = root.join("profile"); + let hold_path = root.join("browser-hold"); + let hold_path_c = std::ffi::CString::new(hold_path.as_os_str().as_bytes()).unwrap(); + // SAFETY: hold_path_c is a valid, NUL-terminated path in the test directory. + assert_eq!(unsafe { libc::mkfifo(hold_path_c.as_ptr(), 0o600) }, 0); + let (descriptor_sender, descriptor_receiver) = mpsc::sync_channel(1); + let (release_sender, release_receiver) = mpsc::sync_channel(1); + let release_receiver = Arc::new(std::sync::Mutex::new(release_receiver)); + let (connector_id_sender, connector_id_receiver) = mpsc::sync_channel(1); + let (connector_start_sender, connector_start_receiver) = mpsc::sync_channel(1); + let connector = std::thread::spawn(move || { + connector_id_sender.send(std::thread::current().id()).unwrap(); + connector_start_receiver.recv().unwrap(); + connect_unix_until(&socket_path, Instant::now() + Duration::from_secs(15)) + }); + let connector_id = connector_id_receiver.recv_timeout(Duration::from_secs(5)).unwrap(); + imp::set_socket_created_hook(Some(Arc::new({ + move |descriptor| { + if std::thread::current().id() != connector_id { + return; + } + descriptor_sender.send(descriptor).unwrap(); + release_receiver + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(15)) + .expect("test did not release descriptor setup"); + } + }))); + + connector_start_sender.send(()).unwrap(); + let descriptor = descriptor_receiver + .recv_timeout(Duration::from_secs(15)) + .expect("connector did not expose its descriptor setup window"); + std::fs::write( + &browser_path, + format!( + "#!/bin/sh\n\ + if [ -e /dev/fd/{descriptor} ]; then\n\ + printf inherited > {}\n\ + else\n\ + printf closed > {}\n\ + fi\n\ + printf 'DevTools listening on ws://127.0.0.1:1/devtools/browser/test\\n' >&2\n\ + exec /bin/cat < {}\n", + marker_path.display(), + marker_path.display(), + hold_path.display() + ), + ) + .unwrap(); + let mut permissions = std::fs::metadata(&browser_path).unwrap().permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&browser_path, permissions).unwrap(); + + let (launch_started_sender, launch_started_receiver) = mpsc::sync_channel(1); + let (launch_result_sender, launch_result_receiver) = mpsc::sync_channel(1); + let launcher = std::thread::spawn(move || { + launch_started_sender.send(()).unwrap(); + let result = + cmux_tui_cdp::Chrome::launch_with(&cmux_tui_cdp::ChromeLaunchOptions { + binary: browser_path, + mode: cmux_tui_cdp::BrowserMode::Headless, + user_data_dir: Some(profile_path), + ephemeral: false, + }) + .map(drop) + .map_err(|error| error.to_string()); + launch_result_sender.send(result).unwrap(); + }); + launch_started_receiver.recv_timeout(Duration::from_secs(5)).unwrap(); + let launch_result_before_close_on_exec = + launch_result_receiver.recv_timeout(Duration::from_millis(500)).ok(); + let launched_before_close_on_exec = launch_result_before_close_on_exec.is_some(); + + release_sender.send(()).unwrap(); + let stream = connector.join().unwrap().unwrap(); + imp::set_socket_created_hook(None); + let (_accepted, _) = listener.accept().unwrap(); + let launch_result = launch_result_before_close_on_exec.unwrap_or_else(|| { + launch_result_receiver + .recv_timeout(Duration::from_secs(15)) + .expect("browser process did not launch after descriptor setup completed") + }); + launcher.join().unwrap(); + let descriptor_state = std::fs::read_to_string(&marker_path).unwrap(); + + drop(stream); + drop(listener); + std::fs::remove_dir_all(&root).unwrap(); + launch_result.unwrap(); + assert!( + !launched_before_close_on_exec, + "browser process launched while the connector descriptor was still inheritable" + ); + assert_eq!( + descriptor_state, "closed", + "browser process inherited the connector descriptor" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn deadline_connect_bounds_process_barrier_wait() { + use std::sync::mpsc; + + let _serial = socket_created_test_lock(); + let path = std::path::PathBuf::from("/tmp").join(format!( + "cmux-connect-barrier-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let process_barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (result_sender, result_receiver) = mpsc::sync_channel(1); + let connector = std::thread::spawn({ + let path = path.clone(); + move || { + let started = Instant::now(); + let result = + connect_unix_until(&path, started + Duration::from_millis(40)).map(drop); + result_sender.send((started.elapsed(), result)).unwrap(); + } + }); + + let before_release = result_receiver.recv_timeout(Duration::from_millis(150)); + drop(process_barrier); + let completed_before_release = before_release.is_ok(); + let (elapsed, result) = before_release.unwrap_or_else(|_| { + result_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("connector did not finish after the process barrier was released") + }); + connector.join().unwrap(); + drop(listener); + std::fs::remove_file(path).unwrap(); + + assert!( + completed_before_release, + "deadline connector waited for an unbounded process barrier" + ); + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::TimedOut, + "barrier deadline returned the wrong transport error" + ); + assert!( + elapsed < Duration::from_millis(150), + "barrier deadline exceeded its wall-clock bound: {elapsed:?}" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn transport_listener_creation_waits_for_the_process_barrier() { + use std::sync::mpsc; + + let _serial = socket_created_test_lock(); + let path = std::path::PathBuf::from("/tmp").join(format!( + "cmux-listen-barrier-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let process_barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (result_sender, result_receiver) = mpsc::sync_channel(1); + let creator = std::thread::spawn({ + let path = path.clone(); + move || result_sender.send(listen(&path)).unwrap() + }); + + assert!( + result_receiver.recv_timeout(Duration::from_millis(250)).is_err(), + "Unix listener creation bypassed the process-wide descriptor barrier" + ); + drop(process_barrier); + let listener = result_receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(); + creator.join().unwrap(); + + drop(listener); + std::fs::remove_file(path).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn deadline_connect_times_out_while_listener_backlog_is_saturated() { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + + let path = std::env::temp_dir().join(format!( + "cmux-platform-backlog-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let (address, address_len) = imp::unix_socket_address(&path).unwrap(); + // SAFETY: socket has no pointer arguments and returns a new owned + // descriptor on success. + let listener = + unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) }; + assert!(listener >= 0, "failed to create the test listener"); + // SAFETY: listener is a fresh successful socket result and this + // OwnedFd takes its sole ownership. + let listener = unsafe { OwnedFd::from_raw_fd(listener) }; + // SAFETY: address is initialized for this exact filesystem path + // and listener owns a valid AF_UNIX descriptor. + let result = unsafe { + libc::bind( + listener.as_raw_fd(), + (&raw const address).cast::(), + address_len, + ) + }; + assert_eq!(result, 0, "failed to bind the test listener"); + // SAFETY: listener remains a valid bound AF_UNIX descriptor. + let result = unsafe { libc::listen(listener.as_raw_fd(), 0) }; + assert_eq!(result, 0, "failed to create a zero-backlog test listener"); + let mut queued = Vec::new(); + let mut timeout_elapsed = None; + for _ in 0..32 { + let started = Instant::now(); + match connect_until(&path, started + Duration::from_millis(75)) { + Ok(stream) => queued.push(stream), + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + timeout_elapsed = Some(started.elapsed()); + break; + } + } + } + let elapsed = + timeout_elapsed.expect("could not saturate the listener backlog with 32 clients"); + assert!( + elapsed < Duration::from_secs(1), + "saturated listener connect exceeded its deadline bound: {elapsed:?}" + ); + drop(queued); + drop(listener); + std::fs::remove_file(path).unwrap(); + } + } } /// Runtime socket/pidfile directory for the current user. @@ -818,6 +1786,19 @@ fn restrict_permissions(_path: &Path, _mode: u32) -> std::io::Result<()> { mod tests { use super::*; + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn unix_transport_reports_the_kernel_peer_process() { + use std::os::unix::net::UnixStream; + + use transport::Stream as _; + + let (client, server) = UnixStream::pair().unwrap(); + + assert_eq!(client.peer_process_id().unwrap(), Some(std::process::id())); + assert_eq!(server.peer_process_id().unwrap(), Some(std::process::id())); + } + fn position(candidates: &[GhosttyInstallation], expected: impl AsRef) -> usize { let expected = expected.as_ref(); candidates diff --git a/cmux-tui/crates/cmux-tui-core/src/process_session.rs b/cmux-tui/crates/cmux-tui-core/src/process_session.rs new file mode 100644 index 000000000000..e69f68f245a9 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/process_session.rs @@ -0,0 +1,2546 @@ +//! Exact ownership helpers for Unix PTY sessions. +//! +//! `portable-pty` starts each terminal child as a session leader. Process +//! groups inside that session are transient job-control details, so shutdown +//! enumerates and signals every session member instead of guessing which +//! foreground or background groups still exist. + +use std::collections::{HashMap, HashSet}; +use std::io; +#[cfg(target_os = "macos")] +use std::mem::size_of; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +const SESSION_KILL_POLL: Duration = Duration::from_millis(5); +const PROCESS_SESSION_PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(1); +const NATURAL_REAP_RETRY_INITIAL: Duration = Duration::from_millis(25); +const NATURAL_REAP_RETRY_MAX: Duration = Duration::from_secs(1); +#[cfg(not(test))] +const NATURAL_REAP_DEGRADED_RETRY: Duration = Duration::from_secs(30); +#[cfg(test)] +const NATURAL_REAP_DEGRADED_RETRY: Duration = Duration::from_millis(500); +const NATURAL_REAP_BATCH_WINDOW: Duration = Duration::from_millis(5); +const NATURAL_REAP_CAPACITY: usize = 4_096; +#[cfg(not(test))] +const NATURAL_REAP_MAX_ATTEMPTS: usize = 8; +#[cfg(test)] +const NATURAL_REAP_MAX_ATTEMPTS: usize = 3; + +#[cfg(test)] +static NATURAL_REAP_WORKERS: AtomicUsize = AtomicUsize::new(0); +#[cfg(test)] +static NATURAL_REAP_TEST_SESSION: AtomicUsize = AtomicUsize::new(1_000_000_000); + +static NATURAL_REAPER: OnceLock>> = OnceLock::new(); +static STABLE_PROCESS_SIGNALING: OnceLock<()> = OnceLock::new(); + +#[cfg(test)] +thread_local! { + static FORCE_PROCESS_SESSION_PREFLIGHT_FAILURE: std::cell::Cell = + const { std::cell::Cell::new(false) }; + static POST_SPAWN_FAILURE_MARKER: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +pub(crate) fn dedicated_natural_reap_workers_for_test() -> usize { + NATURAL_REAP_WORKERS.load(Ordering::Acquire) +} + +#[cfg(test)] +fn natural_reap_test_session() -> libc::pid_t { + libc::pid_t::try_from(NATURAL_REAP_TEST_SESSION.fetch_add(1, Ordering::Relaxed)).unwrap() +} + +#[cfg(test)] +pub(crate) fn reserved_child_reaper_active_for_test() -> usize { + let Some(slot) = NATURAL_REAPER.get() else { return 0 }; + let slot = slot.lock().unwrap(); + slot.as_ref().map_or(0, |reaper| reaper.activity.active.load(Ordering::Acquire)) +} + +#[cfg(test)] +pub(crate) fn wait_for_reserved_child_reaper_active_for_test( + expected: usize, + deadline: Instant, +) -> bool { + let Some(slot) = NATURAL_REAPER.get() else { return expected == 0 }; + let activity = { + let slot = slot.lock().unwrap(); + let Some(reaper) = slot.as_ref() else { return expected == 0 }; + reaper.activity.clone() + }; + activity.wait_for_active(expected, deadline) +} + +#[cfg(test)] +pub(crate) fn set_process_session_preflight_failure_for_test(enabled: bool) { + FORCE_PROCESS_SESSION_PREFLIGHT_FAILURE.set(enabled); +} + +#[cfg(test)] +pub(crate) struct ForcedPostSpawnFailure { + previous: Option, +} + +#[cfg(test)] +impl Drop for ForcedPostSpawnFailure { + fn drop(&mut self) { + POST_SPAWN_FAILURE_MARKER.with_borrow_mut(|marker| { + *marker = self.previous.take(); + }); + } +} + +#[cfg(test)] +pub(crate) fn force_post_spawn_failure_for_test( + marker: std::path::PathBuf, +) -> ForcedPostSpawnFailure { + use std::os::unix::ffi::OsStrExt as _; + + let marker_name = std::ffi::CString::new(marker.as_os_str().as_bytes()) + .expect("post-spawn failure signal path contains a null byte"); + // SAFETY: marker_name is a valid, null-terminated path owned for this call. + let result = unsafe { libc::mkfifo(marker_name.as_ptr(), 0o600) }; + assert_eq!( + result, + 0, + "failed to create post-spawn failure signal FIFO: {}", + io::Error::last_os_error() + ); + let previous = POST_SPAWN_FAILURE_MARKER.replace(Some(marker)); + ForcedPostSpawnFailure { previous } +} + +#[cfg(test)] +pub(crate) fn fail_after_pty_spawn_for_test() -> io::Result<()> { + use std::io::Read as _; + use std::os::fd::AsRawFd as _; + + let marker = POST_SPAWN_FAILURE_MARKER.with_borrow(Clone::clone); + let Some(marker) = marker else { return Ok(()) }; + let mut signal = std::fs::OpenOptions::new().read(true).write(true).open(marker)?; + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "forced post-spawn failure signal was not published", + )); + }; + let timeout = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; + let mut descriptor = + libc::pollfd { fd: signal.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: descriptor points to one initialized pollfd for this call. + let ready = unsafe { libc::poll(&raw mut descriptor, 1, timeout) }; + if ready > 0 && descriptor.revents & libc::POLLIN != 0 { + let mut byte = [0_u8; 1]; + signal.read_exact(&mut byte)?; + break; + } + if ready < 0 && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted { + return Err(io::Error::last_os_error()); + } + } + Err(io::Error::other("forced post-spawn PTY initialization failure")) +} + +/// Synchronization shared by a WNOWAIT child observer and explicit PTY +/// shutdown. The session leader remains reserved until this state machine +/// confirms that cleanup completed and performs the sole final reap. +pub(crate) struct ReservedChildReap<'a> { + pub(crate) signal_lock: &'a Mutex<()>, + pub(crate) pty_drained: &'a AtomicBool, + pub(crate) termination_started: &'a AtomicBool, + pub(crate) cleanup_complete: &'a AtomicBool, + pub(crate) child_reaped: &'a AtomicBool, + pub(crate) wait_ownership_lost: &'a AtomicBool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChildWaitState { + Running, + Waitable, + OwnershipLost, +} + +pub(crate) enum NaturalReapFinish { + Complete, + Pending, + Failed, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum NaturalChildObservation { + Ready, + Pending, + Failed, +} + +enum NaturalReaperCommand { + Add(NaturalReapRequest), + WakeSession(libc::pid_t), + WakeUnpublished, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct UnpublishedChildReaperHealth { + pub(crate) pending: usize, + pub(crate) degraded: bool, +} + +#[derive(Default)] +struct NaturalReaperActivityState { + unpublished: usize, + degraded: usize, + unpublished_degraded: usize, +} + +#[derive(Default)] +struct NaturalReaperActivity { + active: AtomicUsize, + state: Mutex, + changed: Condvar, +} + +impl NaturalReaperActivity { + fn reserve(&self) -> io::Result<()> { + self.active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < NATURAL_REAP_CAPACITY).then_some(active + 1) + }) + .map_err(|_| { + io::Error::new(io::ErrorKind::WouldBlock, "PTY session reaper capacity exhausted") + })?; + self.state.lock().unwrap().unpublished += 1; + self.changed.notify_all(); + Ok(()) + } + + fn attach_owner(&self) { + let mut state = self.state.lock().unwrap(); + state.unpublished = state + .unpublished + .checked_sub(1) + .expect("unpublished PTY child reaper reservation is balanced"); + self.changed.notify_all(); + } + + fn release(&self, owner_attached: bool) { + self.active.fetch_sub(1, Ordering::AcqRel); + let mut state = self.state.lock().unwrap(); + if !owner_attached { + state.unpublished = state + .unpublished + .checked_sub(1) + .expect("unpublished PTY child reaper reservation is balanced"); + } + self.changed.notify_all(); + } + + fn set_degraded(&self, owner_attached: bool, degraded: bool) { + let mut state = self.state.lock().unwrap(); + if degraded { + state.degraded += 1; + if !owner_attached { + state.unpublished_degraded += 1; + } + } else { + state.degraded = state + .degraded + .checked_sub(1) + .expect("degraded PTY child reaper accounting is balanced"); + if !owner_attached { + state.unpublished_degraded = state + .unpublished_degraded + .checked_sub(1) + .expect("unpublished degraded PTY child reaper accounting is balanced"); + } + } + } + + fn unpublished_health(&self) -> UnpublishedChildReaperHealth { + let state = self.state.lock().unwrap(); + UnpublishedChildReaperHealth { + pending: state.unpublished, + degraded: state.unpublished_degraded != 0, + } + } + + fn wait_for_unpublished_until(&self, deadline: Instant) -> io::Result<()> { + let mut state = self.state.lock().unwrap(); + while state.unpublished != 0 { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Err(unpublished_child_reaper_deadline_error(&state)); + }; + let (next, timeout) = self.changed.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && state.unpublished != 0 { + return Err(unpublished_child_reaper_deadline_error(&state)); + } + } + Ok(()) + } + + #[cfg(test)] + fn wait_for_active(&self, expected: usize, deadline: Instant) -> bool { + let mut state = self.state.lock().unwrap(); + while self.active.load(Ordering::Acquire) != expected { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.changed.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && self.active.load(Ordering::Acquire) != expected { + return false; + } + } + true + } +} + +fn unpublished_child_reaper_deadline_error(state: &NaturalReaperActivityState) -> io::Error { + io::Error::new( + io::ErrorKind::TimedOut, + format!( + "unpublished child reaper ownership did not drain before the shutdown deadline \ + (pending: {}, degraded: {})", + state.unpublished, state.unpublished_degraded + ), + ) +} + +struct NaturalReapRequest { + session: libc::pid_t, + cleanup_timeout: Duration, + observe_child: Box io::Result + Send>, + child_observed: bool, + needs_cleanup: Box bool + Send>, + prepare_cleanup: Box bool + Send>, + finish: Box NaturalReapFinish + Send>, + _lease: ReservedChildReaperLease, + next_attempt: Instant, + retry_delay: Duration, + attempts: usize, + degraded: bool, +} + +impl NaturalReapRequest { + fn clear_degraded(&mut self) { + if self.degraded { + self.degraded = false; + self._lease.activity.set_degraded(self._lease.owner_attached, false); + } + } + + fn recover_from_failure(&mut self) { + if self.attempts != 0 || self.degraded { + self.attempts = 0; + self.retry_delay = NATURAL_REAP_RETRY_INITIAL; + self.clear_degraded(); + } + } + + fn schedule_pending(&mut self) { + self.recover_from_failure(); + self.next_attempt = Instant::now() + self.retry_delay; + self.retry_delay = (self.retry_delay * 2).min(NATURAL_REAP_RETRY_MAX); + } + + fn schedule_failure(&mut self) { + self.attempts = self.attempts.saturating_add(1); + if self.attempts >= NATURAL_REAP_MAX_ATTEMPTS { + if !self.degraded { + self.degraded = true; + self._lease.activity.set_degraded(self._lease.owner_attached, true); + } + self.next_attempt = Instant::now() + NATURAL_REAP_DEGRADED_RETRY; + } else { + self.next_attempt = Instant::now() + self.retry_delay; + self.retry_delay = (self.retry_delay * 2).min(NATURAL_REAP_RETRY_MAX); + } + } +} + +impl Drop for NaturalReapRequest { + fn drop(&mut self) { + self.clear_degraded(); + } +} + +struct NaturalReaper { + sender: mpsc::Sender, + activity: Arc, + _worker: std::thread::JoinHandle<()>, +} + +pub(crate) struct ReservedChildReaperLease { + sender: mpsc::Sender, + activity: Arc, + owner_attached: bool, +} + +impl ReservedChildReaperLease { + /// Transfer cleanup authority to a surface or terminal-host owner. + /// + /// Until this transition, shutdown must treat the reservation as the sole + /// owner of any child created after it. Failed-spawn cleanup deliberately + /// keeps the reservation unpublished until the shared reaper completes. + #[must_use] + pub(crate) fn attach_owner(mut self) -> Self { + assert!(!self.owner_attached, "PTY child reaper already has a published owner"); + self.activity.attach_owner(); + self.owner_attached = true; + self + } +} + +impl Drop for ReservedChildReaperLease { + fn drop(&mut self) { + self.activity.release(self.owner_attached); + } +} + +impl NaturalReaper { + fn start() -> io::Result { + let (sender, receiver) = mpsc::channel(); + let activity = Arc::new(NaturalReaperActivity::default()); + let worker = std::thread::Builder::new().name("cmux-pty-session-reaper".into()).spawn( + move || { + #[cfg(test)] + NATURAL_REAP_WORKERS.fetch_add(1, Ordering::AcqRel); + run_natural_reaper(receiver); + #[cfg(test)] + NATURAL_REAP_WORKERS.fetch_sub(1, Ordering::AcqRel); + }, + )?; + Ok(Self { sender, activity, _worker: worker }) + } + + fn lease(&self) -> io::Result { + self.activity.reserve()?; + Ok(ReservedChildReaperLease { + sender: self.sender.clone(), + activity: self.activity.clone(), + owner_attached: false, + }) + } +} + +pub(crate) fn reserve_child_reaper() -> io::Result { + require_waitable_child_disposition()?; + let mut slot = NATURAL_REAPER.get_or_init(|| Mutex::new(None)).lock().unwrap(); + if slot.is_none() { + *slot = Some(NaturalReaper::start()?); + } + slot.as_ref().expect("natural PTY reaper initialized").lease() +} + +pub(crate) fn unpublished_child_reaper_health() -> UnpublishedChildReaperHealth { + let Some(slot) = NATURAL_REAPER.get() else { + return UnpublishedChildReaperHealth::default(); + }; + let activity = { + let slot = slot.lock().unwrap(); + let Some(reaper) = slot.as_ref() else { + return UnpublishedChildReaperHealth::default(); + }; + reaper.activity.clone() + }; + activity.unpublished_health() +} + +/// Wake ownerless child cleanup and retain the server until every reservation +/// either publishes a higher-level owner or releases its wait ownership. +pub(crate) fn wait_for_unpublished_child_reaper_until(deadline: Instant) -> io::Result<()> { + let Some(slot) = NATURAL_REAPER.get() else { return Ok(()) }; + let (sender, activity) = { + let slot = slot.lock().unwrap(); + let Some(reaper) = slot.as_ref() else { return Ok(()) }; + (reaper.sender.clone(), reaper.activity.clone()) + }; + sender.send(NaturalReaperCommand::WakeUnpublished).map_err(|_| { + io::Error::new(io::ErrorKind::BrokenPipe, "unpublished child reaper is unavailable") + })?; + activity.wait_for_unpublished_until(deadline) +} + +pub(crate) fn observe_child_without_reaping( + pid: libc::pid_t, + nonblocking: bool, +) -> io::Result { + loop { + let mut status = std::mem::MaybeUninit::::zeroed(); + let mut flags = libc::WEXITED | libc::WNOWAIT; + if nonblocking { + flags |= libc::WNOHANG; + } + // SAFETY: status points to writable siginfo storage. WNOWAIT keeps + // the child and its numeric session ID reserved until the sole owner + // performs the final wait. + let result = + unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, status.as_mut_ptr(), flags) }; + if result == 0 { + // SAFETY: waitid initialized status on success; zeroed storage + // keeps si_signo at zero when WNOHANG finds no waitable child. + return Ok(if unsafe { status.assume_init() }.si_signo == 0 { + ChildWaitState::Running + } else { + ChildWaitState::Waitable + }); + } + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + if error.raw_os_error() == Some(libc::ECHILD) { + return Ok(ChildWaitState::OwnershipLost); + } + return Err(error); + } +} + +fn require_waitable_child_disposition() -> io::Result<()> { + let mut action = std::mem::MaybeUninit::::zeroed(); + // SAFETY: a null replacement only reads the process-wide SIGCHLD + // disposition into the initialized output storage. + if unsafe { libc::sigaction(libc::SIGCHLD, std::ptr::null(), action.as_mut_ptr()) } < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: sigaction initialized the output on success. + let action = unsafe { action.assume_init() }; + if action.sa_sigaction == libc::SIG_IGN || action.sa_flags & libc::SA_NOCLDWAIT != 0 { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "SIGCHLD disposition does not preserve PTY child wait ownership", + )); + } + Ok(()) +} + +/// Wake one reserved child after a cleanup-relevant state transition. +/// +/// This deliberately preempts that session's degraded retry delay once. +/// Callers must emit it only when the state actually changed; a failed attempt +/// keeps its normal degraded backoff. +pub(crate) fn wake_child_reaper(session: libc::pid_t) { + let Some(slot) = NATURAL_REAPER.get() else { return }; + let slot = slot.lock().unwrap(); + let Some(reaper) = slot.as_ref() else { return }; + let _ = reaper.sender.send(NaturalReaperCommand::WakeSession(session)); +} + +pub(crate) fn enqueue_reserved_session_leader( + lease: ReservedChildReaperLease, + session: libc::pid_t, + cleanup_timeout: Duration, + observe_child: impl FnMut() -> io::Result + Send + 'static, + needs_cleanup: impl Fn() -> bool + Send + 'static, + prepare_cleanup: impl FnMut() -> bool + Send + 'static, + finish: impl FnMut(bool) -> NaturalReapFinish + Send + 'static, +) { + let sender = lease.sender.clone(); + sender + .send(NaturalReaperCommand::Add(NaturalReapRequest { + session, + cleanup_timeout, + observe_child: Box::new(observe_child), + child_observed: false, + needs_cleanup: Box::new(needs_cleanup), + prepare_cleanup: Box::new(prepare_cleanup), + finish: Box::new(finish), + _lease: lease, + next_attempt: Instant::now(), + retry_delay: NATURAL_REAP_RETRY_INITIAL, + attempts: 0, + degraded: false, + })) + .expect("natural PTY reaper remains alive while its service is registered"); +} + +pub(crate) fn reserved_child_needs_cleanup(sync: ReservedChildReap<'_>) -> bool { + let _signal = sync.signal_lock.lock().unwrap(); + !sync.wait_ownership_lost.load(Ordering::Acquire) + && !sync.cleanup_complete.load(Ordering::Acquire) + && !sync.termination_started.load(Ordering::Acquire) +} + +pub(crate) fn poll_reserved_session_leader( + sync: ReservedChildReap<'_>, + cleanup_succeeded: bool, + reap: impl FnOnce(), +) -> bool { + let _signal = sync.signal_lock.lock().unwrap(); + if sync.wait_ownership_lost.load(Ordering::Acquire) { + return false; + } + if !sync.cleanup_complete.load(Ordering::Acquire) { + if sync.termination_started.load(Ordering::Acquire) || !cleanup_succeeded { + return false; + } + sync.cleanup_complete.store(true, Ordering::Release); + } + if !sync.pty_drained.load(Ordering::Acquire) { + return false; + } + reap(); + sync.child_reaped.store(true, Ordering::Release); + true +} + +fn run_natural_reaper(receiver: mpsc::Receiver) { + let mut pending = Vec::::new(); + loop { + let received = if pending.is_empty() { + receiver.recv().map_err(|_| RecvTimeoutError::Disconnected) + } else { + let now = Instant::now(); + let wait = pending + .iter() + .map(|request| request.next_attempt.saturating_duration_since(now)) + .min() + .unwrap_or(NATURAL_REAP_RETRY_MAX); + receiver.recv_timeout(wait) + }; + match received { + Ok(command) => { + accept_natural_reaper_command(command, &mut pending); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) if pending.is_empty() => return, + Err(RecvTimeoutError::Disconnected) => { + std::thread::sleep(NATURAL_REAP_RETRY_MAX); + } + } + + let batch_deadline = Instant::now() + NATURAL_REAP_BATCH_WINDOW; + while let Some(remaining) = batch_deadline.checked_duration_since(Instant::now()) { + match receiver.recv_timeout(remaining) { + Ok(command) => { + accept_natural_reaper_command(command, &mut pending); + } + Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => break, + } + } + + let now = Instant::now(); + let mut due = Vec::new(); + let mut index = 0; + while index < pending.len() { + if pending[index].next_attempt <= now { + due.push(pending.swap_remove(index)); + } else { + index += 1; + } + } + if due.is_empty() { + continue; + } + + let observations = due + .iter_mut() + .map(|request| { + if request.child_observed { + return NaturalChildObservation::Ready; + } + match (request.observe_child)() { + Ok(true) => { + request.child_observed = true; + request.recover_from_failure(); + request.retry_delay = NATURAL_REAP_RETRY_INITIAL; + NaturalChildObservation::Ready + } + Ok(false) => { + request.recover_from_failure(); + NaturalChildObservation::Pending + } + Err(_) => NaturalChildObservation::Failed, + } + }) + .collect::>(); + let needs_cleanup = due + .iter() + .zip(&observations) + .map(|(request, observation)| { + *observation == NaturalChildObservation::Ready && (request.needs_cleanup)() + }) + .collect::>(); + let prepared = due + .iter_mut() + .zip(observations.iter().zip(&needs_cleanup)) + .map(|(request, (observation, needed))| { + *observation != NaturalChildObservation::Ready + || !needed + || (request.prepare_cleanup)() + }) + .collect::>(); + let sessions = due + .iter() + .zip(needs_cleanup.iter().zip(&prepared)) + .filter_map(|(request, (needed, prepared))| { + (*needed && *prepared).then_some(request.session) + }) + .collect::>(); + let cleanup_timeout = due + .iter() + .zip(&needs_cleanup) + .filter_map(|(request, needed)| needed.then_some(request.cleanup_timeout)) + .max() + .unwrap_or_default(); + let cleanup_deadline = Instant::now() + cleanup_timeout; + let cleaned = if sessions.is_empty() { + HashSet::new() + } else { + SessionProcessSnapshot::capture(sessions.iter().copied(), cleanup_deadline) + .map(|snapshot| { + kill_sessions_until_only_leaders_from_snapshot( + &snapshot, + &sessions, + cleanup_deadline, + ) + }) + .unwrap_or_default() + }; + + for (((mut request, observation), needed), prepared) in + due.into_iter().zip(observations).zip(needs_cleanup).zip(prepared) + { + match observation { + NaturalChildObservation::Pending => { + request.schedule_pending(); + pending.push(request); + continue; + } + NaturalChildObservation::Failed => { + request.schedule_failure(); + pending.push(request); + continue; + } + NaturalChildObservation::Ready => {} + } + let cleanup_succeeded = needed && prepared && cleaned.contains(&request.session); + let cleanup_failed = needed && !cleanup_succeeded; + match (request.finish)(cleanup_succeeded) { + NaturalReapFinish::Complete => { + request.clear_degraded(); + } + NaturalReapFinish::Failed => { + request.schedule_failure(); + pending.push(request); + } + NaturalReapFinish::Pending if cleanup_failed => { + request.schedule_failure(); + pending.push(request); + } + NaturalReapFinish::Pending => { + request.schedule_pending(); + pending.push(request); + } + } + } + } +} + +fn kill_sessions_until_only_leaders_from_snapshot( + snapshot: &SessionProcessSnapshot, + sessions: &HashSet, + deadline: Instant, +) -> HashSet { + let mut states = HashMap::)>::new(); + let mut failed = HashSet::new(); + for session in sessions { + match snapshot.members(*session) { + Ok((generation, members)) => { + if signal_members(&members, *session, None).is_ok() { + states.insert(*session, (generation, members)); + } else { + failed.insert(*session); + } + } + Err(_) => { + failed.insert(*session); + } + } + } + + let mut complete = HashSet::new(); + while complete.len() + failed.len() < sessions.len() && Instant::now() < deadline { + let mut refresh = Vec::new(); + for (session, (_, members)) in &states { + if complete.contains(session) || failed.contains(session) { + continue; + } + let drained = members + .iter() + .copied() + .filter(|pid| pid != session) + .map(|pid| still_member(pid, *session)) + .collect::>>() + .map(|members| members.into_iter().all(|alive| !alive)); + match drained { + Ok(true) => refresh.push(*session), + Ok(false) => {} + Err(_) => { + failed.insert(*session); + } + } + } + + if let Some(first) = refresh.first().copied() { + let observed_generation = states[&first].0; + if snapshot.refresh_members(first, observed_generation, deadline).is_err() { + break; + } + for session in refresh { + let Ok((generation, members)) = snapshot.members(session) else { + failed.insert(session); + continue; + }; + if members.iter().all(|pid| *pid == session) { + complete.insert(session); + continue; + } + if signal_members(&members, session, None).is_err() { + failed.insert(session); + continue; + } + states.insert(session, (generation, members)); + } + } + + if complete.len() + failed.len() < sessions.len() { + std::thread::sleep( + deadline.saturating_duration_since(Instant::now()).min(SESSION_KILL_POLL), + ); + } + } + complete +} + +fn accept_natural_reaper_command( + command: NaturalReaperCommand, + pending: &mut Vec, +) { + match command { + NaturalReaperCommand::Add(request) => pending.push(request), + NaturalReaperCommand::WakeSession(session) => { + let now = Instant::now(); + for request in pending.iter_mut().filter(|request| request.session == session) { + request.next_attempt = now; + } + } + NaturalReaperCommand::WakeUnpublished => { + let now = Instant::now(); + for request in pending.iter_mut().filter(|request| !request._lease.owner_attached) { + request.next_attempt = now; + } + } + } +} + +#[cfg(test)] +thread_local! { + static PROCESS_TABLE_SCAN_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; + static STABLE_PROCESS_PREFLIGHT_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; + static RAW_PID_SIGNAL_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; + static STABLE_PROCESS_SIGNAL_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_stable_process_preflight_count_for_test() { + STABLE_PROCESS_PREFLIGHT_COUNT.set(0); +} + +#[cfg(test)] +pub(crate) fn stable_process_preflight_count_for_test() -> usize { + STABLE_PROCESS_PREFLIGHT_COUNT.get() +} + +pub(crate) struct SessionProcessSnapshot { + sessions: HashSet, + state: Mutex, + refreshed: Condvar, + #[cfg(test)] + scan_count: AtomicUsize, +} + +struct SessionProcessSnapshotState { + generation: u64, + members: HashMap>, + refreshing: bool, + failure: Option, +} + +#[derive(Clone)] +struct SessionScanFailure { + kind: io::ErrorKind, + message: String, +} + +impl SessionScanFailure { + fn from_error(error: &io::Error) -> Self { + Self { kind: error.kind(), message: error.to_string() } + } + + fn to_error(&self) -> io::Error { + io::Error::new(self.kind, self.message.clone()) + } +} + +impl SessionProcessSnapshot { + pub(crate) fn capture( + sessions: impl IntoIterator, + deadline: Instant, + ) -> io::Result { + let sessions = sessions.into_iter().collect::>(); + for session in &sessions { + validate_session_target(*session)?; + } + let members = scan_sessions(&sessions, Some(deadline))?; + #[cfg(test)] + let has_sessions = !sessions.is_empty(); + Ok(Self { + sessions, + state: Mutex::new(SessionProcessSnapshotState { + generation: 0, + members, + refreshing: false, + failure: None, + }), + refreshed: Condvar::new(), + #[cfg(test)] + scan_count: AtomicUsize::new(usize::from(has_sessions)), + }) + } + + fn members(&self, session: libc::pid_t) -> io::Result<(u64, Vec)> { + if !self.sessions.contains(&session) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "PTY session is outside the shutdown snapshot", + )); + } + let state = self.state.lock().unwrap(); + if let Some(failure) = &state.failure { + return Err(failure.to_error()); + } + Ok((state.generation, state.members.get(&session).cloned().unwrap_or_default())) + } + + fn refresh_members( + &self, + session: libc::pid_t, + observed_generation: u64, + deadline: Instant, + ) -> io::Result<(u64, Vec)> { + if !self.sessions.contains(&session) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "PTY session is outside the shutdown snapshot", + )); + } + let mut state = self.state.lock().unwrap(); + loop { + if let Some(failure) = &state.failure { + return Err(failure.to_error()); + } + if state.generation != observed_generation { + return Ok(( + state.generation, + state.members.get(&session).cloned().unwrap_or_default(), + )); + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Err(snapshot_deadline_error()); + }; + if !state.refreshing { + state.refreshing = true; + break; + } + let (next, timeout) = self.refreshed.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && state.generation == observed_generation { + return Err(snapshot_deadline_error()); + } + } + drop(state); + + #[cfg(test)] + self.scan_count.fetch_add(1, Ordering::Relaxed); + let scanned = scan_sessions(&self.sessions, Some(deadline)); + let mut state = self.state.lock().unwrap(); + state.refreshing = false; + match scanned { + Ok(members) => { + state.generation = state.generation.saturating_add(1); + state.members = members; + } + Err(error) => { + state.failure = Some(SessionScanFailure::from_error(&error)); + } + } + self.refreshed.notify_all(); + if let Some(failure) = &state.failure { + return Err(failure.to_error()); + } + Ok((state.generation, state.members.get(&session).cloned().unwrap_or_default())) + } + + #[cfg(test)] + fn scan_count(&self) -> usize { + self.scan_count.load(Ordering::Relaxed) + } +} + +pub(crate) fn signal_until( + session: libc::pid_t, + signal: libc::c_int, + deadline: Instant, +) -> io::Result<()> { + let snapshot = SessionProcessSnapshot::capture([session], deadline)?; + signal_from_snapshot(&snapshot, session, signal) +} + +pub(crate) fn signal_from_snapshot( + snapshot: &SessionProcessSnapshot, + session: libc::pid_t, + signal: libc::c_int, +) -> io::Result<()> { + validate_session_target(session)?; + let (_, members) = snapshot.members(session)?; + signal_members_with(&members, session, None, signal) +} + +/// Repeatedly SIGKILL every member until the session contains only its +/// reserved leader (or is empty). Callers must keep the leader unreaped for +/// this entire operation, which prevents the session id from being reused. +pub(crate) fn kill_until_only_leader( + session: libc::pid_t, + leader: libc::pid_t, + deadline: Instant, + leader_exited: impl FnMut() -> io::Result, +) -> io::Result { + validate_session_target(session)?; + let snapshot = SessionProcessSnapshot::capture([session], deadline)?; + kill_until_only_leader_from_snapshot(&snapshot, session, leader, deadline, leader_exited) +} + +pub(crate) fn kill_until_only_leader_from_snapshot( + snapshot: &SessionProcessSnapshot, + session: libc::pid_t, + leader: libc::pid_t, + deadline: Instant, + leader_exited: impl FnMut() -> io::Result, +) -> io::Result { + kill_until_only_reserved_from_snapshot(snapshot, session, leader, deadline, leader_exited, None) +} + +/// Drain a captured session while the caller keeps one exact member stopped. +/// The reserved process prevents session-id reuse and cannot fork, so global +/// membership is reconciled only after each known generation has exited. +pub fn kill_until_only_reserved( + session: libc::pid_t, + reserved: libc::pid_t, + deadline: Instant, +) -> io::Result { + validate_session_target(session)?; + if !still_member(reserved, session)? { + return Err(io::Error::new(io::ErrorKind::NotFound, "reserved PTY session process exited")); + } + let snapshot = SessionProcessSnapshot::capture([session], deadline)?; + kill_until_only_reserved_from_snapshot( + &snapshot, + session, + reserved, + deadline, + || Ok(true), + Some(reserved), + ) +} + +fn kill_until_only_reserved_from_snapshot( + snapshot: &SessionProcessSnapshot, + session: libc::pid_t, + reserved: libc::pid_t, + deadline: Instant, + mut can_reconcile: impl FnMut() -> io::Result, + signal_exclusion: Option, +) -> io::Result { + validate_session_target(session)?; + let (mut generation, mut known_members) = snapshot.members(session)?; + signal_members(&known_members, session, signal_exclusion)?; + loop { + let known_drained = known_members + .iter() + .copied() + .filter(|pid| *pid != reserved) + .map(|pid| still_member(pid, session)) + .collect::>>()? + .into_iter() + .all(|alive| !alive); + if known_drained && can_reconcile()? { + let (current_generation, current) = + snapshot.refresh_members(session, generation, deadline)?; + if current.iter().all(|pid| *pid == reserved) { + return Ok(true); + } + signal_members(¤t, session, signal_exclusion)?; + generation = current_generation; + known_members = current; + } + if Instant::now() >= deadline { + return Ok(false); + } + std::thread::sleep( + deadline.saturating_duration_since(Instant::now()).min(SESSION_KILL_POLL), + ); + } +} + +/// Read-only proof used after every pre-close process identity in a captured +/// legacy PTY session has already disappeared. +pub fn session_is_empty_until(session: libc::pid_t, deadline: Instant) -> io::Result { + validate_session_target(session)?; + members_until(session, deadline).map(|members| members.is_empty()) +} + +/// Snapshot process IDs in a non-caller session. Callers must capture exact +/// process birth identities before using the result for later signaling. +pub fn session_member_pids_until( + session: libc::pid_t, + deadline: Instant, +) -> io::Result> { + validate_session_target(session)?; + members_until(session, deadline) +} + +fn signal_members( + members: &[libc::pid_t], + session: libc::pid_t, + reserved: Option, +) -> io::Result<()> { + signal_members_with(members, session, reserved, libc::SIGKILL) +} + +fn signal_members_with( + members: &[libc::pid_t], + session: libc::pid_t, + reserved: Option, + signal: libc::c_int, +) -> io::Result<()> { + for pid in members.iter().copied().filter(|pid| Some(*pid) != reserved) { + signal_if_still_member(pid, session, signal)?; + } + Ok(()) +} + +fn validate_session_target(session: libc::pid_t) -> io::Result<()> { + if session <= 1 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid PTY session id")); + } + // SAFETY: getsid(0) only queries the calling process. + if unsafe { libc::getsid(0) } == session { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "refusing to signal the mux process session", + )); + } + Ok(()) +} + +fn signal_if_still_member( + pid: libc::pid_t, + session: libc::pid_t, + signal: libc::c_int, +) -> io::Result<()> { + let Some(process) = stable_process_in_session(pid, session)? else { + return Ok(()); + }; + let _ = process.signal(signal)?; + Ok(()) +} + +fn still_member(pid: libc::pid_t, session: libc::pid_t) -> io::Result { + Ok(active_process_session(pid)? == Some(session)) +} + +fn process_session_id(pid: libc::pid_t) -> io::Result> { + // SAFETY: getsid only queries process metadata. + let current_session = unsafe { libc::getsid(pid) }; + if current_session >= 0 { + return Ok(Some(current_session)); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { Ok(None) } else { Err(error) } +} + +#[cfg(not(target_os = "linux"))] +fn active_process_session(pid: libc::pid_t) -> io::Result> { + process_session_id(pid) +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct LinuxProcessMetadata { + started_at: u128, + zombie: bool, +} + +#[cfg(target_os = "linux")] +fn linux_process_metadata(pid: libc::pid_t) -> io::Result> { + let stat = match std::fs::read(format!("/proc/{pid}/stat")) { + Ok(stat) => stat, + Err(error) + if error.kind() == io::ErrorKind::NotFound + || error.raw_os_error() == Some(libc::ESRCH) => + { + return Ok(None); + } + Err(error) => return Err(error), + }; + let fields = linux_process_stat_fields(pid, &stat)?; + let state = fields.first().copied().ok_or_else(|| io::Error::other("missing process state"))?; + let started_at = fields + .get(19) + .and_then(|value| std::str::from_utf8(value).ok()) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| io::Error::other("invalid process birth identity"))?; + Ok(Some(LinuxProcessMetadata { started_at, zombie: state == b"Z" })) +} + +#[cfg(target_os = "linux")] +fn linux_process_stat_fields(pid: libc::pid_t, stat: &[u8]) -> io::Result> { + let name_start = stat + .windows(2) + .position(|window| window == b" (") + .ok_or_else(|| io::Error::other("invalid process stat record"))?; + let name_end = stat + .windows(2) + .rposition(|window| window == b") ") + .filter(|name_end| *name_end > name_start) + .ok_or_else(|| io::Error::other("invalid process stat record"))?; + let pid_text = std::str::from_utf8(&stat[..name_start]) + .map_err(|_| io::Error::other("invalid process metadata id"))?; + if pid_text.parse::().ok() != Some(pid) { + return Err(io::Error::other("process metadata id mismatch")); + } + Ok(stat[name_end + 2..] + .split(|byte| byte.is_ascii_whitespace()) + .filter(|field| !field.is_empty()) + .collect()) +} + +#[cfg(target_os = "linux")] +fn active_process_session(pid: libc::pid_t) -> io::Result> { + let Some(before) = linux_process_metadata(pid)? else { return Ok(None) }; + if before.zombie { + return Ok(None); + } + + let Some(current_session) = process_session_id(pid)? else { return Ok(None) }; + + // Bracket the reusable PID query with the kernel process birth identity. + // Zombies cannot execute or fork, so shutdown may treat them as drained + // while the reserved leader still prevents session-id reuse. + let Some(after) = linux_process_metadata(pid)? else { return Ok(None) }; + if after.zombie || after.started_at != before.started_at { + return Ok(None); + } + Ok(Some(current_session)) +} + +#[cfg(target_os = "linux")] +fn tracked_process_session( + pid: libc::pid_t, + sessions: &HashSet, +) -> io::Result> { + // Query the kernel session first so unrelated PIDs never require procfs + // access on hidepid mounts. Candidate members still pass through the + // birth-identity bracket, and any failure there aborts the snapshot. + tracked_process_session_with(pid, sessions, process_session_id, active_process_session) +} + +#[cfg(target_os = "linux")] +fn tracked_process_session_with( + pid: libc::pid_t, + sessions: &HashSet, + mut query_session: Q, + mut verify_session: V, +) -> io::Result> +where + Q: FnMut(libc::pid_t) -> io::Result>, + V: FnMut(libc::pid_t) -> io::Result>, +{ + let Some(candidate_session) = query_session(pid)? else { return Ok(None) }; + if !sessions.contains(&candidate_session) { + return Ok(None); + } + let Some(verified_session) = verify_session(pid)? else { return Ok(None) }; + Ok(sessions.contains(&verified_session).then_some(verified_session)) +} + +fn stable_process_in_session( + pid: libc::pid_t, + session: libc::pid_t, +) -> io::Result> { + let Some(process) = StableProcessHandle::capture(pid)? else { return Ok(None) }; + // Bracket the PID-based session query with a stable process instance. If + // the PID is recycled during getsid(2), the final identity check fails + // closed and no signal is sent. + let current_session = unsafe { libc::getsid(pid) }; + if current_session < 0 { + let error = io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ESRCH) { Ok(None) } else { Err(error) }; + } + if current_session != session || !process.matches_current()? { + return Ok(None); + } + Ok(Some(process)) +} + +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// An OS-backed reference to one process instance that cannot retarget after PID reuse. +pub struct StableProcessHandle { + pid: libc::pid_t, + instance: crate::unix_process_scope::MacProcessInstanceIdentity, +} + +#[cfg(target_os = "macos")] +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct MacAuditToken { + values: [u32; 8], +} + +#[cfg(target_os = "macos")] +impl StableProcessHandle { + /// Capture the process currently using `pid`, or return `None` if it is already gone. + pub fn capture(pid: libc::pid_t) -> io::Result> { + let Some(instance) = mac_process_instance_identity(pid)? else { return Ok(None) }; + Ok(Some(Self { pid, instance })) + } + + /// Return whether this exact process instance is still alive. + pub fn matches_current(&self) -> io::Result { + Ok(mac_process_instance_identity(self.pid)? + .is_some_and(|instance| instance == self.instance)) + } + + /// Signal this exact process instance, returning false if it is already gone. + pub fn signal(&self, signal: libc::c_int) -> io::Result { + let signal_process = resolve_proc_signal_with_audittoken()?; + let expected_pid = u32::try_from(self.pid) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid process id"))?; + let Some(current) = crate::unix_process_scope::mac_process_signal_identity(expected_pid) + else { + return match mac_process_instance_identity(self.pid)? { + None => Ok(false), + Some(instance) if instance != self.instance => Ok(false), + Some(_) => Err(io::Error::other(format!( + "cannot acquire exact signal identity for PID {}", + self.pid + ))), + }; + }; + if current.instance != self.instance { + return Ok(false); + } + let mut token = MacAuditToken { values: [u32::MAX; 8] }; + token.values[5] = expected_pid; + token.values[7] = current.pid_version; + loop { + // SAFETY: the PID and PID version came from kernel process + // metadata; libproc validates them together before signaling. + let result = unsafe { signal_process(&mut token, signal) }; + if result == 0 { + #[cfg(test)] + STABLE_PROCESS_SIGNAL_COUNT.set(STABLE_PROCESS_SIGNAL_COUNT.get() + 1); + return Ok(true); + } + if result == libc::ESRCH { + #[cfg(test)] + STABLE_PROCESS_SIGNAL_COUNT.set(STABLE_PROCESS_SIGNAL_COUNT.get() + 1); + return Ok(false); + } + if result != libc::EINTR { + return Err(io::Error::from_raw_os_error(result)); + } + } + } +} + +#[cfg(target_os = "macos")] +fn mac_process_instance_identity( + pid: libc::pid_t, +) -> io::Result> { + let expected_pid = u32::try_from(pid) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid process id"))?; + if let Some(instance) = crate::unix_process_scope::mac_process_instance_identity(expected_pid) { + return Ok(Some(instance)); + } + if process_is_gone(pid)? { + Ok(None) + } else { + Err(io::Error::other(format!( + "cannot acquire stable process identity for PID {pid}: kernel process instance is unavailable" + ))) + } +} + +#[cfg(target_os = "macos")] +fn process_is_gone(pid: libc::pid_t) -> io::Result { + // SAFETY: signal zero performs a non-mutating liveness/permission probe. + if unsafe { libc::kill(pid, 0) } == 0 { + return Ok(false); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::ESRCH) => Ok(true), + Some(libc::EPERM) => Ok(false), + _ => Err(error), + } +} + +#[cfg(target_os = "macos")] +type ProcSignalWithAuditToken = + unsafe extern "C" fn(*mut MacAuditToken, libc::c_int) -> libc::c_int; + +#[cfg(target_os = "macos")] +fn resolve_proc_signal_with_audittoken() -> io::Result { + static SYMBOL: OnceLock> = OnceLock::new(); + SYMBOL + .get_or_init(|| { + // SAFETY: RTLD_DEFAULT searches already loaded images. The + // resolved address is used only with the documented libproc ABI. + let address = + unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"proc_signal_with_audittoken".as_ptr()) }; + if address.is_null() { + return None; + } + // SAFETY: dlsym returned the address of the named C function. + Some(unsafe { + std::mem::transmute::<*mut libc::c_void, ProcSignalWithAuditToken>(address) + }) + }) + .as_ref() + .copied() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "exact process signaling is unavailable on this macOS version", + ) + }) +} + +#[cfg(target_os = "linux")] +#[derive(Debug)] +/// An OS-backed reference to one process instance that cannot retarget after PID reuse. +pub struct StableProcessHandle { + process_fd: std::os::fd::OwnedFd, +} + +#[cfg(target_os = "linux")] +impl StableProcessHandle { + /// Capture the process currently using `pid`, or return `None` if it is already gone. + pub fn capture(pid: libc::pid_t) -> io::Result> { + use std::os::fd::FromRawFd; + + // SAFETY: pidfd_open receives a validated integer PID and zero flags. + let descriptor = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; + if descriptor >= 0 { + let descriptor = i32::try_from(descriptor) + .map_err(|_| io::Error::other("pidfd descriptor is out of range"))?; + // SAFETY: a successful pidfd_open returns one newly owned descriptor. + return Ok(Some(Self { + process_fd: unsafe { std::os::fd::OwnedFd::from_raw_fd(descriptor) }, + })); + } + let pidfd_error = io::Error::last_os_error(); + if pidfd_error.raw_os_error() == Some(libc::ESRCH) { + return Ok(None); + } + + // Linux 5.1 and 5.2 predate pidfd_open but pidfd_send_signal accepts + // an open /proc/PID directory as the same exact process reference. + match std::fs::File::open(format!("/proc/{pid}")) { + Ok(process_directory) => Ok(Some(Self { process_fd: process_directory.into() })), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io::Error::new( + error.kind(), + format!( + "cannot acquire stable process identity: pidfd_open failed ({pidfd_error}); \ + /proc fallback failed ({error})" + ), + )), + } + } + + /// Return whether this exact process instance is still alive. + pub fn matches_current(&self) -> io::Result { + self.signal_result(0) + } + + /// Signal this exact process instance, returning false if it is already gone. + pub fn signal(&self, signal: libc::c_int) -> io::Result { + let signaled = self.signal_result(signal)?; + #[cfg(test)] + STABLE_PROCESS_SIGNAL_COUNT.set(STABLE_PROCESS_SIGNAL_COUNT.get() + 1); + Ok(signaled) + } + + fn signal_result(&self, signal: libc::c_int) -> io::Result { + use std::os::fd::AsRawFd; + + // SAFETY: the descriptor is a live pidfd, the siginfo pointer is + // intentionally null, and flags must be zero. + let result = unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + self.process_fd.as_raw_fd(), + signal, + std::ptr::null::(), + 0, + ) + }; + if result == 0 { + return Ok(true); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { Ok(false) } else { Err(error) } + } +} + +/// Verify that this runtime can enumerate PTY session members and signal exact +/// process instances before any PTY is spawned or shutdown topology mutates. +pub fn require_stable_process_signaling() -> io::Result<()> { + if STABLE_PROCESS_SIGNALING.get().is_some() { + return Ok(()); + } + require_stable_process_signaling_until(Instant::now() + PROCESS_SESSION_PREFLIGHT_TIMEOUT)?; + let _ = STABLE_PROCESS_SIGNALING.set(()); + Ok(()) +} + +/// Cache a successful process-level capability check once per server +/// generation. Transient failures remain retryable; shutdown still performs a +/// fresh bounded preflight immediately before mutating topology. +pub(crate) fn initialize_stable_process_signaling() { + let _ = require_stable_process_signaling(); +} + +pub(crate) fn require_cached_stable_process_signaling() -> io::Result<()> { + require_stable_process_signaling() +} + +pub(crate) fn require_stable_process_signaling_until(deadline: Instant) -> io::Result<()> { + #[cfg(test)] + STABLE_PROCESS_PREFLIGHT_COUNT.set(STABLE_PROCESS_PREFLIGHT_COUNT.get() + 1); + #[cfg(test)] + if FORCE_PROCESS_SESSION_PREFLIGHT_FAILURE.get() { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "forced process-session preflight failure", + )); + } + require_waitable_child_disposition()?; + let pid = libc::pid_t::try_from(std::process::id()) + .map_err(|_| io::Error::new(io::ErrorKind::Unsupported, "invalid process id"))?; + let process = StableProcessHandle::capture(pid)?.ok_or_else(|| { + io::Error::new(io::ErrorKind::Unsupported, "current process identity is unavailable") + })?; + #[cfg(target_os = "macos")] + let retained = { + resolve_proc_signal_with_audittoken().map_err(|error| { + io::Error::new( + io::ErrorKind::Unsupported, + format!("stable process signaling is unavailable: {error}"), + ) + })?; + process.matches_current() + }; + #[cfg(not(target_os = "macos"))] + let retained = process.signal(0); + match retained { + Ok(true) => {} + Ok(false) => { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "stable process signaling did not retain the current process", + )); + } + Err(error) => { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("stable process signaling is unavailable: {error}"), + )); + } + } + all_process_ids(Some(deadline)).map(|_| ()).map_err(|error| { + io::Error::new( + io::ErrorKind::Unsupported, + format!("PTY session enumeration is unavailable: {error}"), + ) + }) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +/// Placeholder on platforms without stable process signaling. +pub struct StableProcessHandle; + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +impl StableProcessHandle { + /// Report that stable process handles are unsupported on this platform. + pub fn capture(_pid: libc::pid_t) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "stable process signaling is unavailable on this platform", + )) + } + + /// Return false because no stable process handle is available. + pub fn matches_current(&self) -> io::Result { + Ok(false) + } + + /// Report that stable process signaling is unsupported on this platform. + pub fn signal(&self, _signal: libc::c_int) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "stable process signaling is unavailable on this platform", + )) + } +} + +#[cfg(all(target_os = "macos", test))] +fn members(session: libc::pid_t) -> io::Result> { + let sessions = HashSet::from([session]); + Ok(scan_sessions(&sessions, None)?.remove(&session).unwrap_or_default()) +} + +fn members_until(session: libc::pid_t, deadline: Instant) -> io::Result> { + let sessions = HashSet::from([session]); + Ok(scan_sessions(&sessions, Some(deadline))?.remove(&session).unwrap_or_default()) +} + +fn scan_sessions( + sessions: &HashSet, + deadline: Option, +) -> io::Result>> { + #[cfg(target_os = "linux")] + { + scan_sessions_with( + sessions, + deadline, + || all_process_ids(deadline), + |pid| tracked_process_session(pid, sessions), + ) + } + #[cfg(not(target_os = "linux"))] + { + scan_sessions_with(sessions, deadline, || all_process_ids(deadline), active_process_session) + } +} + +fn scan_sessions_with( + sessions: &HashSet, + deadline: Option, + process_ids: P, + mut process_session: S, +) -> io::Result>> +where + P: FnOnce() -> io::Result>, + S: FnMut(libc::pid_t) -> io::Result>, +{ + let mut members = + sessions.iter().copied().map(|session| (session, Vec::new())).collect::>(); + if sessions.is_empty() { + return Ok(members); + } + #[cfg(test)] + PROCESS_TABLE_SCAN_COUNT.set(PROCESS_TABLE_SCAN_COUNT.get() + 1); + for pid in process_ids()? { + ensure_before_deadline(deadline)?; + if pid <= 1 { + continue; + } + let Some(current_session) = process_session(pid)? else { continue }; + ensure_before_deadline(deadline)?; + if let Some(session_members) = members.get_mut(¤t_session) { + session_members.push(pid); + } + } + for session_members in members.values_mut() { + session_members.sort_unstable(); + session_members.dedup(); + } + Ok(members) +} + +fn ensure_before_deadline(deadline: Option) -> io::Result<()> { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(snapshot_deadline_error()); + } + Ok(()) +} + +fn snapshot_deadline_error() -> io::Error { + io::Error::new(io::ErrorKind::TimedOut, "process-session snapshot deadline expired") +} + +#[cfg(test)] +fn shutdown_batch_members_for_test( + sessions: &[libc::pid_t], + deadline: Instant, +) -> io::Result>> { + let snapshot = SessionProcessSnapshot::capture(sessions.iter().copied(), deadline)?; + sessions.iter().map(|session| snapshot.members(*session).map(|(_, members)| members)).collect() +} + +#[cfg(all(target_os = "macos", test))] +fn macos_session_process_ids(session: libc::pid_t) -> io::Result> { + members(session) +} + +#[cfg(target_os = "macos")] +fn all_process_ids(deadline: Option) -> io::Result> { + use std::ffi::c_void; + + // Callers retain this snapshot and poll its members directly. A new full + // process-table scan happens only after that generation has drained. + ensure_before_deadline(deadline)?; + // SAFETY: a null buffer asks libproc for the current process count. + let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + if count < 0 { + return Err(io::Error::last_os_error()); + } + let mut capacity = + usize::try_from(count).map_err(|_| io::Error::other("invalid process count"))? + 32; + for _ in 0..4 { + ensure_before_deadline(deadline)?; + let mut pids = vec![0; capacity]; + let bytes = capacity + .checked_mul(size_of::()) + .and_then(|bytes| i32::try_from(bytes).ok()) + .ok_or_else(|| io::Error::other("process list buffer overflow"))?; + // SAFETY: `pids` owns a writable buffer of `bytes` bytes. + let listed = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast::(), bytes) }; + if listed < 0 { + return Err(io::Error::last_os_error()); + } + let listed = + usize::try_from(listed).map_err(|_| io::Error::other("invalid process count"))?; + if listed < capacity { + pids.truncate(listed); + return Ok(pids); + } + capacity = capacity + .checked_mul(2) + .ok_or_else(|| io::Error::other("process list buffer overflow"))?; + } + Err(io::Error::other("process list did not stabilize")) +} + +#[cfg(target_os = "linux")] +fn all_process_ids(deadline: Option) -> io::Result> { + let mut pids = Vec::new(); + for entry in std::fs::read_dir("/proc")? { + ensure_before_deadline(deadline)?; + let entry = match entry { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + if let Some(pid) = + entry.file_name().to_str().and_then(|name| name.parse::().ok()) + { + pids.push(pid); + } + } + Ok(pids) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn all_process_ids(_deadline: Option) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "PTY session enumeration is unavailable on this platform", + )) +} + +#[cfg(test)] +mod tests { + #[cfg(any(target_os = "macos", target_os = "linux"))] + use std::os::unix::process::CommandExt; + #[cfg(any(target_os = "macos", target_os = "linux"))] + use std::process::{Command, Stdio}; + + #[cfg(any(target_os = "macos", target_os = "linux"))] + use super::*; + + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn spawn_session_child() -> std::process::Child { + let mut command = Command::new("sleep"); + command.arg("60").stdout(Stdio::null()).stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + command.spawn().unwrap() + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn natural_cleanup_does_not_wait_for_pty_drain() { + let signal_lock = Mutex::new(()); + let pty_drained = AtomicBool::new(false); + let termination_started = AtomicBool::new(false); + let cleanup_complete = AtomicBool::new(false); + let child_reaped = AtomicBool::new(false); + let wait_ownership_lost = AtomicBool::new(false); + let reap_called = AtomicBool::new(false); + let sync = || ReservedChildReap { + signal_lock: &signal_lock, + pty_drained: &pty_drained, + termination_started: &termination_started, + cleanup_complete: &cleanup_complete, + child_reaped: &child_reaped, + wait_ownership_lost: &wait_ownership_lost, + }; + + assert!( + reserved_child_needs_cleanup(sync()), + "a descendant holding the PTY open prevented its own cleanup" + ); + assert!( + !poll_reserved_session_leader(sync(), true, || { + reap_called.store(true, Ordering::Release); + }), + "the session leader was reaped before the PTY reader drained" + ); + assert!( + cleanup_complete.load(Ordering::Acquire), + "successful descendant cleanup was not recorded before PTY drain" + ); + assert!(!child_reaped.load(Ordering::Acquire)); + assert!(!reap_called.load(Ordering::Acquire)); + + pty_drained.store(true, Ordering::Release); + assert!(poll_reserved_session_leader(sync(), false, || { + reap_called.store(true, Ordering::Release); + })); + assert!(child_reaped.load(Ordering::Acquire)); + assert!(reap_called.load(Ordering::Acquire)); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn natural_reaper_degrades_persistent_observation_errors() { + let release = Arc::new(AtomicBool::new(false)); + let observe_release = release.clone(); + let (attempt_sender, attempt_receiver) = mpsc::channel(); + let (finished_sender, finished_receiver) = mpsc::channel(); + let session = natural_reap_test_session(); + enqueue_reserved_session_leader( + reserve_child_reaper().unwrap().attach_owner(), + session, + Duration::ZERO, + move || { + let _ = attempt_sender.send(()); + if observe_release.load(Ordering::Acquire) { + Ok(true) + } else { + Err(io::Error::other("injected persistent observation error")) + } + }, + || false, + || true, + move |_| { + let _ = finished_sender.send(()); + NaturalReapFinish::Complete + }, + ); + + for _ in 0..NATURAL_REAP_MAX_ATTEMPTS { + attempt_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + } + let retried_while_degraded = + attempt_receiver.recv_timeout(Duration::from_millis(250)).is_ok(); + release.store(true, Ordering::Release); + wake_child_reaper(session); + finished_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + + assert!( + !retried_while_degraded, + "persistent observation errors kept the shared child reaper on its hot retry cadence" + ); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn natural_reaper_degrades_unresolved_finish_without_session_cleanup() { + let release = Arc::new(AtomicBool::new(false)); + let finish_release = release.clone(); + let (attempt_sender, attempt_receiver) = mpsc::channel(); + let (finished_sender, finished_receiver) = mpsc::channel(); + let session = natural_reap_test_session(); + enqueue_reserved_session_leader( + reserve_child_reaper().unwrap().attach_owner(), + session, + Duration::ZERO, + || Ok(true), + || false, + || true, + move |_| { + let _ = attempt_sender.send(()); + if finish_release.load(Ordering::Acquire) { + let _ = finished_sender.send(()); + NaturalReapFinish::Complete + } else { + NaturalReapFinish::Failed + } + }, + ); + + for _ in 0..NATURAL_REAP_MAX_ATTEMPTS { + attempt_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + } + let retried_while_degraded = + attempt_receiver.recv_timeout(Duration::from_millis(250)).is_ok(); + release.store(true, Ordering::Release); + wake_child_reaper(session); + finished_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + + assert!( + !retried_while_degraded, + "an unresolved ownership-loss finish kept the shared child reaper on its hot retry cadence" + ); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn degraded_reap_request_for_test( + session: libc::pid_t, + next_attempt: Instant, + activity: Arc, + ) -> NaturalReapRequest { + let (sender, _receiver) = mpsc::channel(); + NaturalReapRequest { + session, + cleanup_timeout: Duration::ZERO, + observe_child: Box::new(|| Ok(false)), + child_observed: false, + needs_cleanup: Box::new(|| false), + prepare_cleanup: Box::new(|| true), + finish: Box::new(|_| NaturalReapFinish::Pending), + _lease: ReservedChildReaperLease { sender, activity, owner_attached: true }, + next_attempt, + retry_delay: NATURAL_REAP_RETRY_MAX, + attempts: NATURAL_REAP_MAX_ATTEMPTS, + degraded: true, + } + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn natural_reaper_activity_for_test( + active: usize, + degraded: usize, + ) -> Arc { + Arc::new(NaturalReaperActivity { + active: AtomicUsize::new(active), + state: Mutex::new(NaturalReaperActivityState { + unpublished: 0, + degraded, + unpublished_degraded: 0, + }), + changed: Condvar::new(), + }) + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn accept_session_state_change_for_test( + session: libc::pid_t, + pending: &mut Vec, + ) { + accept_natural_reaper_command(NaturalReaperCommand::WakeSession(session), pending); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn state_change_wake_preempts_degraded_retry_before_shutdown_deadline() { + let now = Instant::now(); + let shutdown_deadline = now + Duration::from_secs(5); + let activity = natural_reaper_activity_for_test(1, 1); + let mut pending = vec![degraded_reap_request_for_test( + 41, + now + Duration::from_secs(30), + activity.clone(), + )]; + accept_session_state_change_for_test(41, &mut pending); + + assert!( + pending[0].next_attempt <= shutdown_deadline, + "the production degraded retry outlived the server shutdown deadline" + ); + assert!(pending[0].degraded); + assert_eq!(activity.state.lock().unwrap().degraded, 1); + + let rescheduled_at = Instant::now(); + pending[0].schedule_failure(); + assert!( + pending[0].next_attempt.saturating_duration_since(rescheduled_at) + >= NATURAL_REAP_DEGRADED_RETRY.saturating_sub(Duration::from_millis(10)), + "an unsuccessful state-change retry disabled degraded backoff" + ); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn state_change_wake_preserves_unrelated_degraded_backoff() { + let now = Instant::now(); + let shutdown_deadline = now + Duration::from_secs(5); + let activity = natural_reaper_activity_for_test(2, 2); + let mut pending = vec![ + degraded_reap_request_for_test(41, now + Duration::from_secs(30), activity.clone()), + degraded_reap_request_for_test(73, now + Duration::from_secs(30), activity), + ]; + accept_session_state_change_for_test(41, &mut pending); + + assert!(pending[0].next_attempt <= shutdown_deadline); + assert!( + pending[1].next_attempt > shutdown_deadline, + "one session state change defeated another degraded session's backoff" + ); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn shutdown_wake_preempts_only_unpublished_degraded_reapers() { + let now = Instant::now(); + let shutdown_deadline = now + Duration::from_secs(5); + let activity = Arc::new(NaturalReaperActivity { + active: AtomicUsize::new(2), + state: Mutex::new(NaturalReaperActivityState { + unpublished: 1, + degraded: 2, + unpublished_degraded: 1, + }), + changed: Condvar::new(), + }); + let mut unpublished = + degraded_reap_request_for_test(41, now + Duration::from_secs(30), activity.clone()); + unpublished._lease.owner_attached = false; + let published = + degraded_reap_request_for_test(73, now + Duration::from_secs(30), activity.clone()); + let mut pending = vec![unpublished, published]; + + accept_natural_reaper_command(NaturalReaperCommand::WakeUnpublished, &mut pending); + + assert!(pending[0].next_attempt <= shutdown_deadline); + assert!( + pending[1].next_attempt > shutdown_deadline, + "shutdown defeated a published surface reaper's degraded backoff" + ); + assert_eq!( + activity.unpublished_health(), + UnpublishedChildReaperHealth { pending: 1, degraded: true } + ); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn ignored_sigchld_fails_before_reserving_a_child_reaper() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_IGNORED_SIGCHLD"; + const TEST_NAME: &str = + "process_session::tests::ignored_sigchld_fails_before_reserving_a_child_reaper"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "SIGCHLD preflight subprocess failed: {status}"); + return; + } + + // SAFETY: this isolated one-test subprocess intentionally changes its + // process-wide child disposition and exits immediately after the + // preflight assertion. + let previous = unsafe { libc::signal(libc::SIGCHLD, libc::SIG_IGN) }; + assert_ne!(previous, libc::SIG_ERR); + + let Err(error) = reserve_child_reaper() else { + panic!("ignored SIGCHLD passed the child-wait ownership preflight"); + }; + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + + #[cfg(target_os = "linux")] + fn enter_syscall_blocked_subprocess( + child_env: &str, + test_name: &str, + syscalls: &[libc::c_long], + ) -> bool { + if std::env::var_os(child_env).is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name]) + .env(child_env, "1") + .status() + .unwrap(); + assert!(status.success(), "pidfd compatibility subprocess failed: {status}"); + return false; + } + + let mut filter = vec![libc::sock_filter { + code: u16::try_from(libc::BPF_LD | libc::BPF_W | libc::BPF_ABS).unwrap(), + jt: 0, + jf: 0, + k: 0, + }]; + for syscall in syscalls { + filter.extend([ + libc::sock_filter { + code: u16::try_from(libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K).unwrap(), + jt: 0, + jf: 1, + k: u32::try_from(*syscall).unwrap(), + }, + libc::sock_filter { + code: u16::try_from(libc::BPF_RET | libc::BPF_K).unwrap(), + jt: 0, + jf: 0, + k: libc::SECCOMP_RET_ERRNO | u32::try_from(libc::ENOSYS).unwrap(), + }, + ]); + } + filter.push(libc::sock_filter { + code: u16::try_from(libc::BPF_RET | libc::BPF_K).unwrap(), + jt: 0, + jf: 0, + k: libc::SECCOMP_RET_ALLOW, + }); + let program = libc::sock_fprog { + len: u16::try_from(filter.len()).unwrap(), + filter: filter.as_mut_ptr(), + }; + // SAFETY: this one-test subprocess opts into a filter whose backing + // instructions remain live for the prctl call. + assert_eq!(unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }, 0); + // SAFETY: `program` describes the initialized filter above. + let installed = unsafe { + libc::prctl( + libc::PR_SET_SECCOMP, + libc::SECCOMP_MODE_FILTER, + std::ptr::from_ref(&program), + ) + }; + assert_eq!( + installed, + 0, + "could not install pidfd test filter: {}", + io::Error::last_os_error() + ); + true + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_process_identity_survives_exec() { + use std::io::{BufRead, BufReader, Write}; + + let mut child = Command::new("/bin/sh") + .arg("-c") + .arg("read _; exec /bin/sh -c 'printf \"ready\\n\"; read _'") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = libc::pid_t::try_from(child.id()).unwrap(); + let process = StableProcessHandle::capture(pid).unwrap().unwrap(); + let mut input = child.stdin.take().unwrap(); + let mut output = BufReader::new(child.stdout.take().unwrap()); + + writeln!(input, "continue").unwrap(); + input.flush().unwrap(); + let mut ready = String::new(); + output.read_line(&mut ready).unwrap(); + assert_eq!(ready, "ready\n"); + + let matches_after_exec = process.matches_current(); + let signal_after_exec = process.signal(libc::SIGCONT); + + writeln!(input, "finish").unwrap(); + input.flush().unwrap(); + assert!(child.wait().unwrap().success()); + assert!(matches_after_exec.unwrap()); + assert!(signal_after_exec.unwrap()); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_child_process_has_an_exact_signal_identity() { + let mut child = spawn_session_child(); + let pid = libc::pid_t::try_from(child.id()).unwrap(); + let result = (|| -> io::Result<(bool, bool)> { + let process = StableProcessHandle::capture(pid)?.ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "child exited before identity capture") + })?; + Ok((process.matches_current()?, process.signal(libc::SIGCONT)?)) + })(); + let _ = child.kill(); + let _ = child.wait(); + + let (matches, signaled) = result.unwrap(); + assert!(matches); + assert!(signaled); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_session_query_is_scoped_to_the_owned_session() { + let mut child = spawn_session_child(); + let session = libc::pid_t::try_from(child.id()).unwrap(); + + let members = macos_session_process_ids(session).unwrap(); + + let current = libc::pid_t::try_from(std::process::id()).unwrap(); + assert!(members.contains(&session)); + assert!(!members.contains(¤t)); + child.kill().unwrap(); + child.wait().unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_binary_does_not_strongly_import_optional_signal_api() { + let output = Command::new("/usr/bin/nm") + .arg("-u") + .arg(std::env::current_exe().unwrap()) + .output() + .unwrap(); + assert!(output.status.success()); + let imports = String::from_utf8(output.stdout).unwrap(); + + assert!( + !imports.lines().any(|line| line.contains("_proc_signal_with_audittoken")), + "an optional post-deployment-target API remained a strong dyld import" + ); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn session_members_are_never_signaled_through_a_reusable_pid() { + let mut child = spawn_session_child(); + let session = libc::pid_t::try_from(child.id()).unwrap(); + RAW_PID_SIGNAL_COUNT.set(0); + STABLE_PROCESS_SIGNAL_COUNT.set(0); + + signal_until(session, libc::SIGCONT, Instant::now() + Duration::from_secs(1)).unwrap(); + let raw_signals = RAW_PID_SIGNAL_COUNT.get(); + let stable_signals = STABLE_PROCESS_SIGNAL_COUNT.get(); + + child.kill().unwrap(); + child.wait().unwrap(); + assert_eq!( + raw_signals, 0, + "session cleanup used a reusable PID instead of a stable process identity" + ); + assert_eq!(stable_signals, 1, "session cleanup did not use the stable process handle"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_process_metadata_accepts_a_non_utf8_process_name() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_NON_UTF8_PROCESS_NAME"; + const TEST_NAME: &str = + "process_session::tests::linux_process_metadata_accepts_a_non_utf8_process_name"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "non-UTF-8 proc metadata subprocess failed: {status}"); + return; + } + + let pid = libc::pid_t::try_from(std::process::id()).unwrap(); + std::fs::write(format!("/proc/self/task/{pid}/comm"), b"cmux-\xff").unwrap(); + + let metadata = + linux_process_metadata(pid).expect("a valid non-UTF-8 proc name was rejected"); + assert!(metadata.is_some()); + } + + #[cfg(target_os = "linux")] + #[test] + fn shutdown_treats_zombie_members_as_drained_before_reserved_parent() { + use std::io::BufRead as _; + + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "trap '' HUP TERM; sleep 60 & echo $!; wait"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut leader = command.spawn().unwrap(); + let session = libc::pid_t::try_from(leader.id()).unwrap(); + let stdout = leader.stdout.take().unwrap(); + let (descendant_sender, descendant_receiver) = mpsc::sync_channel(1); + let descendant_reader = std::thread::spawn(move || { + let mut line = String::new(); + let result = io::BufReader::new(stdout).read_line(&mut line).map(|_| line); + let _ = descendant_sender.send(result); + }); + let descendant = match descendant_receiver.recv_timeout(Duration::from_secs(1)) { + Ok(Ok(line)) => line.trim().parse::().unwrap(), + result => { + let _ = leader.kill(); + let _ = leader.wait(); + let _ = descendant_reader.join(); + panic!("session descendant PID was not published within one second: {result:?}"); + } + }; + descendant_reader.join().unwrap(); + let leader_handle = StableProcessHandle::capture(session).unwrap().unwrap(); + assert!(leader_handle.signal(libc::SIGSTOP).unwrap()); + let mut status = 0; + loop { + let waited = unsafe { libc::waitpid(session, &mut status, libc::WUNTRACED) }; + if waited == session { + assert!(libc::WIFSTOPPED(status)); + break; + } + assert_eq!(waited, -1); + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + panic!("could not observe reserved session leader stop: {error}"); + } + } + + let drained = + kill_until_only_reserved(session, session, Instant::now() + Duration::from_secs(1)); + + let _ = unsafe { libc::kill(descendant, libc::SIGKILL) }; + let _ = leader_handle.signal(libc::SIGCONT); + let _ = leader.kill(); + let _ = leader.wait(); + assert!(drained.unwrap(), "an unreaped zombie kept a fully terminated PTY session pending"); + } + + #[cfg(target_os = "linux")] + #[test] + fn pidfd_open_denial_uses_an_exact_proc_handle() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_BLOCK_PIDFD_OPEN"; + if !enter_syscall_blocked_subprocess( + CHILD_ENV, + "process_session::tests::pidfd_open_denial_uses_an_exact_proc_handle", + &[libc::SYS_pidfd_open], + ) { + return; + } + + let pid = libc::pid_t::try_from(std::process::id()).unwrap(); + let process = StableProcessHandle::capture(pid).unwrap().unwrap(); + assert!(process.matches_current().unwrap()); + } + + #[cfg(target_os = "linux")] + #[test] + fn missing_exact_signal_support_fails_the_runtime_preflight() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_BLOCK_PIDFD_SIGNALING"; + if !enter_syscall_blocked_subprocess( + CHILD_ENV, + "process_session::tests::missing_exact_signal_support_fails_the_runtime_preflight", + &[libc::SYS_pidfd_open, libc::SYS_pidfd_send_signal], + ) { + return; + } + + let error = require_stable_process_signaling().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + + #[cfg(target_os = "linux")] + #[test] + fn missing_process_enumeration_fails_the_runtime_preflight() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_BLOCK_PROCESS_ENUMERATION"; + if !enter_syscall_blocked_subprocess( + CHILD_ENV, + "process_session::tests::missing_process_enumeration_fails_the_runtime_preflight", + &[libc::SYS_getdents64], + ) { + return; + } + + let error = require_stable_process_signaling().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn successful_preflight_survives_a_transient_initializer_failure() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_TRANSIENT_PREFLIGHT_CACHE"; + const TEST_NAME: &str = + "process_session::tests::successful_preflight_survives_a_transient_initializer_failure"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "preflight cache subprocess failed: {status}"); + return; + } + + require_stable_process_signaling().unwrap(); + set_process_session_preflight_failure_for_test(true); + initialize_stable_process_signaling(); + set_process_session_preflight_failure_for_test(false); + + require_cached_stable_process_signaling() + .expect("a later transient scan replaced the successful startup preflight"); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn shutdown_batch_reuses_one_process_table_snapshot() { + let mut children = [spawn_session_child(), spawn_session_child()]; + let sessions = children + .iter() + .map(|child| libc::pid_t::try_from(child.id()).unwrap()) + .collect::>(); + PROCESS_TABLE_SCAN_COUNT.set(0); + + let snapshots = + shutdown_batch_members_for_test(&sessions, Instant::now() + Duration::from_secs(1)); + let scan_count = PROCESS_TABLE_SCAN_COUNT.get(); + + for child in &mut children { + child.kill().unwrap(); + child.wait().unwrap(); + } + let snapshots = snapshots.unwrap(); + assert!( + snapshots.iter().zip(&sessions).all(|(members, session)| members.contains(session)) + ); + assert_eq!( + scan_count, 1, + "one shutdown batch scanned the global process table more than once" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn session_scan_does_not_assume_an_inaccessible_pid_is_foreign() { + let sessions = HashSet::from([41]); + let error = scan_sessions_with( + &sessions, + None, + || Ok(vec![73]), + |_| Err(io::Error::from_raw_os_error(libc::EACCES)), + ) + .expect_err("unknown session membership was silently treated as foreign"); + assert_eq!(error.raw_os_error(), Some(libc::EACCES)); + } + + #[cfg(target_os = "linux")] + #[test] + fn tracked_session_filter_reads_proc_metadata_only_for_candidates() { + let sessions = HashSet::from([41]); + let metadata_queried = std::cell::Cell::new(false); + let foreign = tracked_process_session_with( + 73, + &sessions, + |_| Ok(Some(99)), + |_| { + metadata_queried.set(true); + Err(io::Error::from_raw_os_error(libc::EACCES)) + }, + ) + .unwrap(); + assert_eq!(foreign, None); + assert!(!metadata_queried.get()); + + let error = tracked_process_session_with( + 73, + &sessions, + |_| Ok(Some(41)), + |_| Err(io::Error::from_raw_os_error(libc::EACCES)), + ) + .expect_err("candidate session metadata failure was ignored"); + assert_eq!(error.raw_os_error(), Some(libc::EACCES)); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn concurrent_shutdown_refreshes_share_one_new_snapshot_generation() { + let mut children = [spawn_session_child(), spawn_session_child()]; + let sessions = children + .iter() + .map(|child| libc::pid_t::try_from(child.id()).unwrap()) + .collect::>(); + let deadline = Instant::now() + Duration::from_secs(1); + let snapshot = + Arc::new(SessionProcessSnapshot::capture(sessions.clone(), deadline).unwrap()); + let barrier = Arc::new(std::sync::Barrier::new(sessions.len())); + + std::thread::scope(|scope| { + for session in &sessions { + let snapshot = snapshot.clone(); + let barrier = barrier.clone(); + scope.spawn(move || { + let generation = snapshot.members(*session).unwrap().0; + barrier.wait(); + snapshot.refresh_members(*session, generation, deadline).unwrap(); + }); + } + }); + let scan_count = snapshot.scan_count(); + + for child in &mut children { + child.kill().unwrap(); + child.wait().unwrap(); + } + assert_eq!( + scan_count, 2, + "concurrent refreshes did not coalesce onto one new process-table snapshot" + ); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn natural_reap_batch_refreshes_all_sessions_with_one_scan() { + let mut children = [spawn_session_child(), spawn_session_child()]; + let sessions = children + .iter() + .map(|child| libc::pid_t::try_from(child.id()).unwrap()) + .collect::>(); + let deadline = Instant::now() + Duration::from_secs(1); + let snapshot = SessionProcessSnapshot::capture(sessions.iter().copied(), deadline).unwrap(); + + let completed = + kill_sessions_until_only_leaders_from_snapshot(&snapshot, &sessions, deadline); + let scan_count = snapshot.scan_count(); + + for child in &mut children { + child.wait().unwrap(); + } + assert_eq!(completed, sessions); + assert_eq!(scan_count, 2, "one natural-reap batch did not share its reconciliation scan"); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/provider_management.rs b/cmux-tui/crates/cmux-tui-core/src/provider_management.rs index 522e53a7ed7d..1cf2033fce9d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/provider_management.rs +++ b/cmux-tui/crates/cmux-tui-core/src/provider_management.rs @@ -8,9 +8,13 @@ use std::io::{self, Read, Write}; #[cfg(target_os = "linux")] use std::path::Path; #[cfg(target_os = "linux")] -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +#[cfg(any(target_os = "linux", test))] +use std::sync::mpsc; #[cfg(target_os = "linux")] -use std::time::Duration; +use std::sync::{Arc, Mutex}; +#[cfg(target_os = "linux")] +use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use zeroize::Zeroize; @@ -216,40 +220,159 @@ fn peer_uid(stream: &std::os::unix::net::UnixStream) -> io::Result { Ok(credentials.uid) } -/// Serves the systemd-provided listener in a detached thread. Each peer is -/// credential-checked before any request bytes are read. +/// Owns the systemd-provided listener and its credential-checked peers. +#[cfg(target_os = "linux")] +pub struct ProviderManagementServer { + shutdown: Arc, + thread: Option>, + completion: Option>, +} + +#[cfg(all(test, target_os = "linux"))] +struct ProviderPeerTestHook { + entered: mpsc::SyncSender<()>, + release: Mutex>, +} + +#[cfg(all(test, target_os = "linux"))] +static PROVIDER_PEER_TEST_HOOK: Mutex>> = Mutex::new(None); + +#[cfg(all(test, target_os = "linux"))] +static PROVIDER_PEER_TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(all(test, target_os = "linux"))] +fn wait_for_provider_peer_test_release() { + let hook = PROVIDER_PEER_TEST_HOOK.lock().unwrap().clone(); + if let Some(hook) = hook { + let _ = hook.entered.send(()); + let _ = hook.release.lock().unwrap().recv(); + } +} + +#[cfg(any(target_os = "linux", test))] +fn reap_finished_peer_threads(peers: &mut Vec>) { + let mut index = 0; + while index < peers.len() { + if peers[index].is_finished() { + let peer = peers.swap_remove(index); + let _ = peer.join(); + } else { + index += 1; + } + } +} + +#[cfg(target_os = "linux")] +impl ProviderManagementServer { + pub fn shutdown_until(&mut self, deadline: Instant) -> bool { + self.shutdown.store(true, Ordering::Release); + let Some(completion) = self.completion.as_ref() else { return true }; + let remaining = deadline.saturating_duration_since(Instant::now()); + if completion.recv_timeout(remaining).is_err() { + return false; + } + self.completion.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + true + } +} + +#[cfg(target_os = "linux")] +impl Drop for ProviderManagementServer { + fn drop(&mut self) { + // The listener thread owns its listener and peer handles. A plain drop + // requests shutdown and leaves that thread to finish its owned cleanup. + self.shutdown.store(true, Ordering::Release); + } +} + +/// Serve the systemd-provided listener until the returned owner is dropped. +/// `shutdown_until` confirms that every active peer and management thread has +/// finished. Dropping without that confirmation requests detached cleanup. #[cfg(target_os = "linux")] pub fn serve( listener: std::os::unix::net::UnixListener, mux: Arc, -) -> io::Result> { +) -> io::Result { let result = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) }; if result != 0 { return Err(io::Error::last_os_error()); } - std::thread::Builder::new().name("provider-management".into()).spawn(move || { - for connection in listener.incoming() { - let Ok(stream) = connection else { continue }; - let mux = mux.clone(); - let _ = std::thread::Builder::new().name("provider-management-peer".into()).spawn( - move || { - let Ok(uid) = peer_uid(&stream) else { return }; - if uid != 0 { - let _ = write_response( - &stream, - &Response::error("access_denied", "provider management requires root"), - ); - return; + listener.set_nonblocking(true)?; + let shutdown = Arc::new(AtomicBool::new(false)); + let thread_shutdown = shutdown.clone(); + let (completion_tx, completion_rx) = mpsc::sync_channel(1); + let thread = + std::thread::Builder::new().name("provider-management".into()).spawn(move || { + let active = Arc::new(Mutex::new(std::collections::HashMap::new())); + let next_peer = AtomicU64::new(1); + let mut peers = Vec::new(); + while !thread_shutdown.load(Ordering::Acquire) { + reap_finished_peer_threads(&mut peers); + let stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + continue; } - let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); - let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); - let Ok(bytes) = read_message(&stream) else { return }; - let response = handle_request(&mux, uid, &bytes.0); - let _ = write_response(&stream, &response); - }, - ); - } - }) + Err(_) => { + if thread_shutdown.load(Ordering::Acquire) { + break; + } + std::thread::sleep(Duration::from_millis(10)); + continue; + } + }; + let peer = next_peer.fetch_add(1, Ordering::Relaxed); + let tracked = match stream.try_clone() { + Ok(tracked) => tracked, + Err(_) => continue, + }; + active.lock().unwrap().insert(peer, tracked); + let mux = mux.clone(); + let peer_active = active.clone(); + match std::thread::Builder::new().name("provider-management-peer".into()).spawn( + move || { + #[cfg(test)] + wait_for_provider_peer_test_release(); + let result = (|| { + let uid = peer_uid(&stream)?; + if uid != 0 { + return write_response( + &stream, + &Response::error( + "access_denied", + "provider management requires root", + ), + ); + } + stream.set_read_timeout(Some(IO_TIMEOUT))?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + let bytes = read_message(&stream)?; + let response = handle_request(&mux, uid, &bytes.0); + write_response(&stream, &response) + })(); + peer_active.lock().unwrap().remove(&peer); + let _ = result; + }, + ) { + Ok(handle) => peers.push(handle), + Err(_) => { + active.lock().unwrap().remove(&peer); + } + } + } + for stream in active.lock().unwrap().values() { + let _ = stream.shutdown(std::net::Shutdown::Both); + } + for peer in peers { + let _ = peer.join(); + } + let _ = completion_tx.send(()); + })?; + Ok(ProviderManagementServer { shutdown, thread: Some(thread), completion: Some(completion_rx) }) } #[derive(Debug)] @@ -362,6 +485,32 @@ mod tests { use super::*; use crate::SurfaceOptions; use std::sync::Arc; + use std::time::{Duration, Instant}; + + #[cfg(target_os = "linux")] + struct ProviderPeerTestHookGuard; + + #[cfg(target_os = "linux")] + impl ProviderPeerTestHookGuard { + fn install() -> (Self, mpsc::Receiver<()>, mpsc::SyncSender<()>) { + let (entered, entered_rx) = mpsc::sync_channel(1); + let (release, release_rx) = mpsc::sync_channel(1); + let previous = + PROVIDER_PEER_TEST_HOOK.lock().unwrap().replace(Arc::new(ProviderPeerTestHook { + entered, + release: Mutex::new(release_rx), + })); + assert!(previous.is_none(), "provider peer test hook was already installed"); + (Self, entered_rx, release) + } + } + + #[cfg(target_os = "linux")] + impl Drop for ProviderPeerTestHookGuard { + fn drop(&mut self) { + PROVIDER_PEER_TEST_HOOK.lock().unwrap().take(); + } + } const MUX_GENERATION: &str = "0123456789abcdef0123456789abcdef"; const AUTHORITY_ONE: &str = "provider-authority-one-0000000000000001"; @@ -386,6 +535,91 @@ mod tests { assert_eq!(response.error.unwrap().code, "access_denied"); } + #[test] + fn completed_provider_peer_threads_are_reaped_while_server_runs() { + let completed = std::thread::spawn(|| {}); + let deadline = Instant::now() + Duration::from_secs(1); + while !completed.is_finished() { + assert!(Instant::now() < deadline, "peer thread did not finish"); + std::thread::yield_now(); + } + let (release_tx, release_rx) = mpsc::channel(); + let active = std::thread::spawn(move || release_rx.recv().unwrap()); + let mut peers = vec![completed, active]; + + reap_finished_peer_threads(&mut peers); + + assert_eq!(peers.len(), 1, "completed peer handle remained retained"); + release_tx.send(()).unwrap(); + for peer in peers { + peer.join().unwrap(); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn dropping_server_does_not_wait_without_limit_for_a_tracked_peer() { + use std::os::unix::net::{UnixListener, UnixStream}; + + let _test_guard = PROVIDER_PEER_TEST_LOCK.lock().unwrap(); + let (hook_guard, entered, release) = ProviderPeerTestHookGuard::install(); + let socket = std::env::temp_dir() + .join(format!("cmux-provider-management-drop-deadline-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + let listener = UnixListener::bind(&socket).unwrap(); + let server = serve(listener, mux()).unwrap(); + let client = UnixStream::connect(&socket).unwrap(); + let peer_entered = entered.recv_timeout(Duration::from_secs(1)).is_ok(); + let (dropped, drop_result) = mpsc::sync_channel(1); + let dropper = std::thread::spawn(move || { + drop(server); + let _ = dropped.send(()); + }); + + let prompt = drop_result.recv_timeout(Duration::from_secs(1)).is_ok(); + let _ = release.send(()); + drop(client); + let dropper_result = dropper.join(); + drop(hook_guard); + let _ = std::fs::remove_file(socket); + + assert!(peer_entered, "the provider peer did not reach its tracked owner signal"); + assert!(dropper_result.is_ok(), "the provider server drop helper panicked"); + assert!(prompt, "provider server drop waited without limit for a tracked peer"); + } + + #[cfg(target_os = "linux")] + #[test] + fn shutdown_until_retains_owner_and_receipt_across_a_missed_deadline() { + use std::os::unix::net::{UnixListener, UnixStream}; + + let _test_guard = PROVIDER_PEER_TEST_LOCK.lock().unwrap(); + let (hook_guard, entered, release) = ProviderPeerTestHookGuard::install(); + let socket = std::env::temp_dir() + .join(format!("cmux-provider-management-shutdown-retry-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + let listener = UnixListener::bind(&socket).unwrap(); + let mut server = serve(listener, mux()).unwrap(); + let client = UnixStream::connect(&socket).unwrap(); + let peer_entered = entered.recv_timeout(Duration::from_secs(1)).is_ok(); + + let first = server.shutdown_until(Instant::now()); + let retained = server.thread.is_some() && server.completion.is_some(); + let _ = release.send(()); + drop(client); + let second = server.shutdown_until(Instant::now() + Duration::from_secs(5)); + let cleared = server.thread.is_none() && server.completion.is_none(); + drop(server); + drop(hook_guard); + let _ = std::fs::remove_file(socket); + + assert!(peer_entered, "the provider peer did not reach its tracked owner signal"); + assert!(!first, "an expired deadline reported provider shutdown completion"); + assert!(retained, "a missed deadline discarded the provider owner or completion receipt"); + assert!(second, "the released provider peer did not complete within the final deadline"); + assert!(cleared, "confirmed provider shutdown retained its owner or completion receipt"); + } + #[cfg(target_os = "linux")] #[test] fn explicit_unsupported_response_is_the_only_upgrade_signal() { diff --git a/cmux-tui/crates/cmux-tui-core/src/publication.rs b/cmux-tui/crates/cmux-tui-core/src/publication.rs new file mode 100644 index 000000000000..5b003f017e0b --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/publication.rs @@ -0,0 +1,258 @@ +//! Cross-process ownership for publishing one durable filesystem identity. + +#[cfg(unix)] +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::sync::{Condvar, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use fs4::FileExt; + +pub(crate) fn publication_lock_path(path: &Path) -> std::io::Result { + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "publication target has no parent directory", + ) + })?; + let file_name = path.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "publication target has no file name") + })?; + let mut lock_name = std::ffi::OsString::from("."); + lock_name.push(file_name); + lock_name.push(".publish.lock"); + Ok(parent.join(lock_name)) +} + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct PublicationLockIdentity { + device: u64, + inode: u64, +} + +#[cfg(unix)] +static PUBLICATION_LOCAL_STATE: OnceLock<(Mutex>, Condvar)> = + OnceLock::new(); + +#[cfg(unix)] +struct PublicationLocalGuard { + identity: PublicationLockIdentity, +} + +#[cfg(unix)] +impl PublicationLocalGuard { + fn acquire( + identity: PublicationLockIdentity, + path: &Path, + deadline: Instant, + ) -> std::io::Result { + let (held, changed) = PUBLICATION_LOCAL_STATE.get_or_init(Default::default); + let mut held = held.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + if held.insert(identity) { + return Ok(Self { identity }); + } + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| publication_timeout(path))?; + held = match changed.wait_timeout(held, remaining) { + Ok((held, _)) => held, + Err(error) => error.into_inner().0, + }; + } + } +} + +#[cfg(unix)] +impl Drop for PublicationLocalGuard { + fn drop(&mut self) { + let (held, changed) = PUBLICATION_LOCAL_STATE.get_or_init(Default::default); + held.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&self.identity); + changed.notify_all(); + } +} + +fn publication_timeout(path: &Path) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("timed out waiting to publish {}", path.display()), + ) +} + +/// Exclusive authority to mutate one durable filesystem publication. +/// +/// The lock is released by closing its file descriptor. On Unix, callers can +/// deliberately inherit that descriptor into a helper process so ownership +/// survives a parent-process exit without an unlocked interval. +#[must_use = "dropping the guard releases publication ownership"] +pub struct PublicationGuard { + // Rust drops fields in declaration order. Close this process's file-lock + // reference before reopening local admission for another thread. + file: std::fs::File, + target: PathBuf, + #[cfg(unix)] + _local: PublicationLocalGuard, +} + +impl PublicationGuard { + pub fn acquire(path: &Path, deadline: Instant) -> std::io::Result { + let lock_path = publication_lock_path(path)?; + let mut options = std::fs::OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600).custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + } + let file = options.open(&lock_path)?; + #[cfg(unix)] + let local = { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let metadata = file.metadata()?; + if !metadata.file_type().is_file() || metadata.nlink() != 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "publication lock is not a single regular file", + )); + } + // SAFETY: geteuid has no preconditions and returns the effective + // user that owns files created by this process. + let current_user = unsafe { libc::geteuid() }; + if metadata.uid() != current_user { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "publication lock is owned by another user", + )); + } + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + PublicationLocalGuard::acquire( + PublicationLockIdentity { device: metadata.dev(), inode: metadata.ino() }, + path, + deadline, + )? + }; + #[cfg(not(unix))] + crate::platform::restrict_file(&lock_path)?; + loop { + match FileExt::try_lock(&file) { + Ok(()) => { + return Ok(Self { + file, + target: path.to_path_buf(), + #[cfg(unix)] + _local: local, + }); + } + Err(fs4::TryLockError::WouldBlock) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(fs4::TryLockError::WouldBlock) => return Err(publication_timeout(path)), + Err(fs4::TryLockError::Error(error)) => return Err(error), + } + } + } + + pub fn target(&self) -> &Path { + &self.target + } + + /// Arrange for this guard's lock descriptor to survive `exec` in `command`. + /// + /// The returned descriptor number must be passed to the child, which + /// should validate and adopt it with [`Self::adopt_inherited`]. The parent + /// descriptor remains close-on-exec and continues to own the same lock. + #[cfg(unix)] + pub fn inherit_into(&self, command: &mut std::process::Command) -> std::os::fd::RawFd { + use std::os::fd::AsRawFd as _; + use std::os::unix::process::CommandExt as _; + + let descriptor = self.file.as_raw_fd(); + // SAFETY: fcntl is async-signal-safe, and this only clears + // close-on-exec on the guard-owned descriptor in the child between + // fork and exec. The parent descriptor remains close-on-exec. + unsafe { + command.pre_exec(move || { + let flags = libc::fcntl(descriptor, libc::F_GETFD); + if flags == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::fcntl(descriptor, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + descriptor + } + + /// Validate and adopt a publication descriptor inherited across `exec`. + /// + /// # Safety + /// + /// `descriptor` must be a valid, uniquely owned child-process descriptor + /// produced by [`Self::inherit_into`]. The caller must not use or close it + /// after this function takes ownership. + #[cfg(unix)] + pub unsafe fn adopt_inherited( + path: &Path, + descriptor: std::os::fd::RawFd, + deadline: Instant, + ) -> std::io::Result { + use std::os::fd::{AsRawFd as _, FromRawFd as _}; + use std::os::unix::fs::MetadataExt as _; + + if descriptor <= libc::STDERR_FILENO { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "publication descriptor is invalid", + )); + } + // SAFETY: F_GETFD only validates the caller-owned descriptor. + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFD) }; + if flags == -1 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: the function contract transfers unique ownership of this + // inherited child descriptor into the returned guard. + let file = unsafe { std::fs::File::from_raw_fd(descriptor) }; + // Do not leak publication ownership into any subprocess the helper + // might launch after adoption. + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, flags | libc::FD_CLOEXEC) } == -1 { + return Err(std::io::Error::last_os_error()); + } + let metadata = file.metadata()?; + let lock_path = publication_lock_path(path)?; + let published_metadata = std::fs::symlink_metadata(&lock_path)?; + if !metadata.file_type().is_file() + || metadata.nlink() != 1 + || metadata.dev() != published_metadata.dev() + || metadata.ino() != published_metadata.ino() + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "inherited publication descriptor has the wrong identity", + )); + } + let local = PublicationLocalGuard::acquire( + PublicationLockIdentity { device: metadata.dev(), inode: metadata.ino() }, + path, + deadline, + )?; + // An inherited duplicate shares the already-locked open file + // description. A separately opened descriptor conflicts while the + // legitimate owner is alive, so this also proves lock ownership. + match FileExt::try_lock(&file) { + Ok(()) => Ok(Self { file, target: path.to_path_buf(), _local: local }), + Err(fs4::TryLockError::WouldBlock) => Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "inherited publication descriptor does not own the lock", + )), + Err(fs4::TryLockError::Error(error)) => Err(error), + } + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/release.rs b/cmux-tui/crates/cmux-tui-core/src/release.rs new file mode 100644 index 000000000000..d60d7b0888c8 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/release.rs @@ -0,0 +1,111 @@ +use serde_json::Value; + +/// One-hop package launcher metadata used to replay the current invocation. +pub const LAUNCHER_COMMAND_ENV: &str = "CMUX_TUI_LAUNCHER_COMMAND"; + +/// The build identity that must match between a local client and server. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReleaseIdentity { + pub version: String, + pub build_commit: Option, + pub ghostty_commit: Option, + pub protocol: u32, +} + +impl ReleaseIdentity { + pub fn current(protocol: u32) -> Self { + Self { + version: distribution_version().to_string(), + build_commit: stamped_build_commit().map(str::to_string), + ghostty_commit: stamped_ghostty_commit().map(str::to_string), + protocol, + } + } + + pub fn from_protocol_data(data: &Value) -> Self { + Self { + version: data + .get("version") + .and_then(Value::as_str) + .filter(|version| !version.is_empty()) + .unwrap_or("unknown") + .to_string(), + build_commit: optional_string(data, "build_commit"), + ghostty_commit: optional_string(data, "ghostty_commit"), + protocol: data + .get("protocol") + .and_then(Value::as_u64) + .and_then(|protocol| u32::try_from(protocol).ok()) + .unwrap_or(0), + } + } + + pub fn exactly_matches(&self, other: &Self) -> bool { + self == other + } + + pub fn version_with_build_metadata(&self) -> String { + match (&self.build_commit, &self.ghostty_commit) { + (Some(commit), Some(ghostty)) => { + format!("{} ({commit}; ghostty {ghostty})", self.version) + } + (Some(commit), None) => format!("{} ({commit})", self.version), + (None, _) => self.version.clone(), + } + } +} + +pub fn distribution_version() -> &'static str { + option_env!("CMUX_TUI_DISTRIBUTION_VERSION") + .filter(|version| !version.is_empty()) + .unwrap_or(env!("CARGO_PKG_VERSION")) +} + +pub fn stamped_build_commit() -> Option<&'static str> { + option_env!("CMUX_TUI_BUILD_COMMIT") + .filter(|commit| !commit.is_empty()) + .or_else(|| option_env!("CMUX_MUX_BUILD_COMMIT").filter(|commit| !commit.is_empty())) + .or_else(|| option_env!("CMUX_TUI_SOURCE_COMMIT").filter(|commit| !commit.is_empty())) +} + +pub fn stamped_ghostty_commit() -> Option<&'static str> { + option_env!("CMUX_TUI_GHOSTTY_COMMIT").filter(|commit| !commit.is_empty()).or_else(|| { + option_env!("CMUX_TUI_SOURCE_GHOSTTY_COMMIT").filter(|commit| !commit.is_empty()) + }) +} + +fn optional_string(data: &Value, key: &str) -> Option { + data.get(key).and_then(Value::as_str).filter(|value| !value.is_empty()).map(str::to_string) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn protocol_data_defaults_missing_identity_fields_conservatively() { + assert_eq!( + ReleaseIdentity::from_protocol_data(&json!({"protocol": 9})), + ReleaseIdentity { + version: "unknown".to_string(), + build_commit: None, + ghostty_commit: None, + protocol: 9, + } + ); + } + + #[test] + fn exact_matching_includes_source_identities() { + let current = ReleaseIdentity::current(9); + assert!(current.build_commit.is_some()); + assert!(current.ghostty_commit.is_some()); + let mut other = current.clone(); + assert!(current.exactly_matches(&other)); + + other.build_commit = Some("different".to_string()); + assert!(!current.exactly_matches(&other)); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_api.rs b/cmux-tui/crates/cmux-tui-core/src/resource_api.rs index 92ca267ac45a..46a835247166 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_api.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_api.rs @@ -856,7 +856,7 @@ fn public_layout_node( } } -fn public_browser_snapshot( +pub(crate) fn public_browser_snapshot( tab: &RegistryTab, durable: &RegistryBrowser, surface: Option<&Arc>, @@ -1026,7 +1026,7 @@ mod tests { assert_eq!(terminal["tab_id"], Value::Null); assert_eq!(terminal["tab_ids"], json!([])); assert!(terminal["exit"].is_object()); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs index 0bb13693ecda..da11d3b1aaf4 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs @@ -326,7 +326,12 @@ fn terminal_effect(mux: &Arc, request: ParsedResourceRequest) -> Result fields.clone(), Err(error) => return effects::commit_known_failure(mux, prepared, error), }; + if prepared.operation == "terminal.close" { + let commit = mux.commit_resource_terminal_close_effect( + &terminal_id, + &prepared.idempotency_key, + &prepared.operation, + &prepared.fingerprint, + ); + return finish_projection_commit(mux, prepared, commit); + } let Some(surface_id) = mux.resource_surface_for_terminal(&terminal_id) else { return effects::commit_known_failure( mux, @@ -383,7 +397,6 @@ fn execute_terminal_effect( surface.clear_history().map_err(|error| ActionFailure::Indeterminate(error.to_string())) } "terminal.viewport.scroll" => terminal_scroll_viewport(mux, &surface, &fields), - "terminal.close" => Ok(()), operation => Err(ActionFailure::Known(ResourceError::operation_failed( operation, "stored terminal effect operation is invalid", @@ -394,16 +407,6 @@ fn execute_terminal_effect( return finish_action_failure(mux, prepared, failure); } - if prepared.operation == "terminal.close" { - let commit = mux.commit_resource_terminal_close_effect( - surface_id, - &prepared.idempotency_key, - &prepared.operation, - &prepared.fingerprint, - ); - return finish_projection_commit(mux, prepared, commit); - } - debug_assert!(effects::receipt_only_operation(&prepared.operation)); effects::commit_success_without_changes(mux, prepared, json!({})) } @@ -504,8 +507,9 @@ fn execute_browser_effect( } if prepared.operation == "browser.close" { - let commit = mux.commit_resource_surface_close_effect( + let commit = mux.commit_resource_content_close_effect( surface_id, + &ContentPublicId::Browser(browser_id), &prepared.idempotency_key, &prepared.operation, &prepared.fingerprint, @@ -2414,6 +2418,36 @@ mod tests { assert_eq!(mux.terminal_registry_snapshot().unwrap().revision, before_terminal + 1); } + #[cfg(unix)] + #[test] + fn terminal_close_terminates_a_restored_host_before_acknowledgement() { + let (mux, surface, selectors) = terminal_fixture(None); + let host = mux.resource_terminal_host_identity(&surface).unwrap(); + let detached = mux + .remove_surface_runtime_for_test(surface.id) + .expect("fixture surface is live before simulating delayed adoption"); + assert!(mux.take_discovered_terminal_termination_requests_for_test().is_empty()); + + let closed = dispatch( + &mux, + parsed_request( + "terminal.close", + &selectors, + json!({}), + Some("close-restored-terminal-host"), + ), + ) + .unwrap(); + + assert_eq!(closed["replayed"], false); + assert_eq!( + mux.take_discovered_terminal_termination_requests_for_test(), + vec![(host.terminal_id, Some(host.incarnation))], + "terminal.close acknowledged before terminating the exact restored host" + ); + detached.kill(); + } + #[test] fn terminal_viewport_scroll_uses_one_bounded_receipt_and_one_journal_outcome() { let (mux, surface, selectors) = terminal_fixture(None); @@ -2583,7 +2617,7 @@ mod tests { assert_eq!(replay["replayed"], true); assert_eq!(replay["revision"], closed["revision"]); assert_eq!(mux.resource_events_after(before_resource).unwrap().batches.len(), 1); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/effects.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/effects.rs index ac895ad64a34..06b6cc1e28c3 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/effects.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/effects.rs @@ -75,7 +75,12 @@ fn resolve_preparation( match preparation { ResourceEffectPreparation::Committed { outcome, revision } => { let result = match outcome { - ResourceEffectOutcome::Success(value) => { + ResourceEffectOutcome::Success(mut value) => { + if operation == "terminal.close" { + mux.terminate_workspace_hosts_from_receipt(&value) + .map_err(resource_operation_error)?; + crate::mux::remove_terminal_host_cleanup_receipt(&mut value); + } mutation_result_for_operation(mux, &operation, value, revision, true) } ResourceEffectOutcome::Failure(error) => Err(error), @@ -509,7 +514,7 @@ mod tests { .unwrap(), ResourceEffectPreparation::Execute { resumed: false, .. } )); - mux.shutdown(); + let _ = mux.shutdown(); drop(mux); fingerprint }; @@ -551,7 +556,7 @@ mod tests { None, ) .unwrap(); - mux.shutdown(); + let _ = mux.shutdown(); } { @@ -567,7 +572,7 @@ mod tests { .unwrap(), Some(ResourceEffectPreparation::Committed { revision: 0, .. }) )); - mux.shutdown(); + let _ = mux.shutdown(); } fn assert_tree_excludes(path: &std::path::Path, needle: &[u8]) { diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/session.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/session.rs index 9d2f7a92aa17..e7290c6f968c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/session.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/session.rs @@ -315,7 +315,7 @@ mod tests { #[test] fn stopped_owner_reload_replays_its_known_failure_receipt() { let mux = Mux::new_for_test("session-reload-failure", SurfaceOptions::default()); - mux.shutdown(); + mux.shutdown().unwrap(); let reload = || { dispatch( &mux, diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/topology.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/topology.rs index b11f2ee6692a..f1c78a1bfc80 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/topology.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/topology.rs @@ -771,7 +771,7 @@ mod tests { ) .unwrap(); assert_eq!(current["name"], "later name"); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -829,7 +829,7 @@ mod tests { ) .unwrap(); assert_eq!(current["index"], 0); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -884,7 +884,7 @@ mod tests { ) .is_err() ); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -913,7 +913,7 @@ mod tests { assert_eq!(replay["revision"], created["revision"]); assert_eq!(replay["replayed"], true); assert_eq!(mux.resource_events_after(0).unwrap().batches.len(), 1); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -977,7 +977,7 @@ mod tests { assert_eq!(replay["revision"], closed["revision"], "{operation:?}"); assert_eq!(mux.resource_events_after(before_resource).unwrap().batches.len(), 1); assert_eq!(mux.terminal_registry_snapshot().unwrap().revision, before_terminal); - mux.shutdown(); + let _ = mux.shutdown(); } } @@ -1080,7 +1080,7 @@ mod tests { assert_eq!(mux.terminal_exit_waiter_count_for_test(&terminal_id), 0); } assert_eq!(mux.terminal_exit_state_query_count_for_test(), 6); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -1127,7 +1127,7 @@ mod tests { let snapshot = public_session_snapshot(&mux).unwrap(); assert_eq!(snapshot["workspaces"].as_array().unwrap().len(), 1); assert_eq!(snapshot["terminals"].as_array().unwrap().len(), 1); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -1250,7 +1250,7 @@ mod tests { mux.resource_creation_resolution("projection-retry-correlation").unwrap()["state"], "created" ); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -1352,7 +1352,7 @@ mod tests { assert_eq!(mux.resource_creation_resolution(correlation).unwrap()["state"], "created"); } mux.set_resource_terminal_reservation_hook_for_test(None); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 5cf8fcc38da5..ba7e64f80265 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -22,7 +22,9 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::io::{BufRead, BufReader, Read, Write}; #[cfg(unix)] use std::mem::{offset_of, size_of}; -use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; +#[cfg(test)] +use std::net::TcpListener; +use std::net::{Shutdown, SocketAddr, TcpStream}; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; @@ -47,6 +49,7 @@ use tungstenite::protocol::frame::coding::CloseCode; use tungstenite::{Message, WebSocket, accept_with_config}; use zeroize::Zeroize; +use crate::PublicationGuard; use crate::browser::{ BrowserAttachUpdate, BrowserFrameUpdate, BrowserMouseDispatch, BrowserPointerOwner, }; @@ -57,6 +60,7 @@ use crate::journal_kernel::{JournalDocument, SharedJournalPage, SharedJournalRea use crate::model::{Screen, State, Workspace}; use crate::mux::{DaemonHandoffRequest, ResourceWaitWake, clamp_terminal_size}; use crate::platform::{self, transport}; +use crate::release::ReleaseIdentity; use crate::resource::{ BrowserPublicId, ClientPublicId, ContentPublicId, RequestId as ResourceRequestId, ResourceError, ResourceOperation, ResponseEnvelope as ResourceResponseEnvelope, Selector, @@ -82,6 +86,13 @@ use crate::{ pub const ATTACH_INITIAL_SIZE_CAPABILITY: &str = "attach-initial-size"; const WORKSPACE_REGISTRY_CAPABILITY: &str = "workspace-registry-v1"; +pub const SERVER_SHUTDOWN_CAPABILITY: &str = "server-shutdown-v1"; +pub const SERVER_SHUTDOWN_INCOMPLETE_ERROR: &str = "shutdown_cleanup_incomplete"; +/// Maximum wall-clock time the server spends draining a shutdown request. +/// Clients add their own transport margin to this server-owned bound. +pub const SERVER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +/// Maximum time startup spends determining whether a local session socket is live. +pub const LOCAL_SOCKET_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); pub const GUARDED_BROWSER_POINTER_CAPABILITY: &str = "browser-pointer-frame-guard-v1"; pub const DAEMON_HANDOFF_FORCE_CAPABILITY: &str = "daemon-handoff-force-v1"; pub const VIEWPORT_SPLITS_CAPABILITY: &str = "viewport-splits-v1"; @@ -113,6 +124,8 @@ pub const TERMINAL_LIFECYCLE_PROTOCOL_VERSION: u32 = 11; pub const LIFECYCLE_READINESS_PROTOCOL_VERSION: u32 = 12; pub const PROTOCOL_VERSION: u32 = LIFECYCLE_READINESS_PROTOCOL_VERSION; const PROTOCOL_KEY_TEXT_MAX_BYTES: usize = CLEAR_HISTORY_KEY_TEXT_MAX_BYTES; +#[cfg(unix)] +static SOCKET_CLEANUP_SEQUENCE: AtomicU64 = AtomicU64::new(1); fn advertised_capabilities(bounded_clear_history_fallback_writes: bool) -> Vec<&'static str> { let mut capabilities = vec![ @@ -138,6 +151,12 @@ fn advertised_capabilities(bounded_clear_history_fallback_writes: bool) -> Vec<& if bounded_clear_history_fallback_writes { capabilities.push(CLEAR_HISTORY_KEY_CAPABILITY); } + // Atomic shutdown is advertised only where PTY session descendants can + // be enumerated and signaled through exact process handles. + #[cfg(any(target_os = "macos", target_os = "linux"))] + if crate::process_session::require_cached_stable_process_signaling().is_ok() { + capabilities.push(SERVER_SHUTDOWN_CAPABILITY); + } capabilities } @@ -558,6 +577,7 @@ enum Command { force: bool, }, Ping, + Shutdown, SetClientInfo { #[serde(default)] name: Option, @@ -1380,6 +1400,7 @@ impl std::error::Error for DeliveryClassifiedError { const STREAM_DISCONNECT_POLL: Duration = Duration::from_millis(100); const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(2); +const SHUTDOWN_ACK_WRITE_TIMEOUT: Duration = SERVER_SHUTDOWN_TIMEOUT; const SHUTDOWN_ACK_FLUSH_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(not(test))] const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); @@ -2118,6 +2139,11 @@ trait MessageSink: Send + Sync { self.send_stream(text, stream) } fn send_control(&self, text: Arc) -> std::io::Result<()>; + fn send_control_confirmed( + &self, + text: Arc, + timeout: Duration, + ) -> std::io::Result<()>; fn send_terminal( &self, text: Arc, @@ -2345,6 +2371,20 @@ impl MessageWriter { result } + fn send_control_confirmed(&self, value: &Value, timeout: Duration) -> std::io::Result<()> { + if !self.is_open() { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let result = self + .render_service + .serialize_control(value) + .and_then(|text| self.sink.send_control_confirmed(text, timeout)); + if result.is_err() { + self.close(); + } + result + } + fn is_open(&self) -> bool { self.open.load(Ordering::Acquire) && self.sink.is_open() } @@ -2732,6 +2772,7 @@ struct BoundedOutbound { #[derive(Default)] struct BoundedOutboundState { initial: VecDeque, + confirmed: VecDeque, control: VecDeque, regular: VecDeque, stream_usage: HashMap, @@ -2753,12 +2794,41 @@ struct RegularOutbound { } enum ControlOutbound { - Text(Arc), + Text { text: Arc, completion: Option> }, Flush(std::sync::mpsc::SyncSender<()>), } +struct OutboundMessage { + text: Arc, + completion: Option>, +} + +impl OutboundMessage { + fn finish(mut self, written: bool) { + if let Some(completion) = self.completion.take() { + let _ = completion.send(written); + } + } + + #[cfg(test)] + fn into_test_text(mut self) -> String { + if let Some(completion) = self.completion.take() { + let _ = completion.send(true); + } + self.text.to_string() + } +} + +impl Drop for OutboundMessage { + fn drop(&mut self) { + if let Some(completion) = self.completion.take() { + let _ = completion.send(false); + } + } +} + enum OutboundItem { - Text(Arc), + Text(OutboundMessage), Flush(std::sync::mpsc::SyncSender<()>), } @@ -2920,11 +2990,33 @@ impl BoundedOutbound { fn push_control(&self, text: Arc) -> std::io::Result<()> { let mut state = self.state.lock().unwrap(); - Self::push_control_locked(&mut state, text)?; + Self::push_control_locked(&mut state, text, None, false)?; self.changed.notify_one(); Ok(()) } + fn push_control_confirmed( + &self, + text: Arc, + timeout: Duration, + ) -> std::io::Result<()> { + let (completion, written) = std::sync::mpsc::sync_channel(1); + { + let mut state = self.state.lock().unwrap(); + Self::push_control_locked(&mut state, text, Some(completion), true)?; + self.changed.notify_one(); + } + match written.recv_timeout(timeout) { + Ok(true) => Ok(()), + Ok(false) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "response write failed")) + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "response write timed out")) + } + } + } + fn flush_control(&self, timeout: Duration) -> std::io::Result<()> { let (flushed_tx, flushed_rx) = std::sync::mpsc::sync_channel(1); let mut state = self.state.lock().unwrap(); @@ -2958,7 +3050,7 @@ impl BoundedOutbound { if stream.terminal_enqueued.swap(true, Ordering::AcqRel) { return Ok(()); } - Self::push_control_locked(&mut state, text)?; + Self::push_control_locked(&mut state, text, None, false)?; self.changed.notify_one(); Ok(()) } @@ -3032,7 +3124,7 @@ impl BoundedOutbound { return Ok(()); } let overflow_text = stream.overflow_text.lock().unwrap().clone(); - if let Err(error) = Self::push_control_locked(state, overflow_text) { + if let Err(error) = Self::push_control_locked(state, overflow_text, None, false) { state.closed = true; return Err(std::io::Error::new( std::io::ErrorKind::BrokenPipe, @@ -3062,6 +3154,8 @@ impl BoundedOutbound { fn push_control_locked( state: &mut BoundedOutboundState, text: Arc, + completion: Option>, + confirmed: bool, ) -> std::io::Result<()> { if state.closed { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); @@ -3077,7 +3171,12 @@ impl BoundedOutbound { } state.control_messages += 1; state.control_bytes += bytes; - state.control.push_back(ControlOutbound::Text(text)); + let message = ControlOutbound::Text { text, completion }; + if confirmed { + state.confirmed.push_back(message); + } else { + state.control.push_back(message); + } Ok(()) } @@ -3086,10 +3185,10 @@ impl BoundedOutbound { let mut state = self.state.lock().unwrap(); loop { match Self::pop_locked(&mut state) { - Some(OutboundItem::Text(text)) => { + Some(OutboundItem::Text(message)) => { drop(state); self.changed.notify_all(); - return Some(text.to_string()); + return Some(message.into_test_text()); } Some(OutboundItem::Flush(flushed)) => { drop(state); @@ -3118,23 +3217,34 @@ impl BoundedOutbound { } fn pop_locked(state: &mut BoundedOutboundState) -> Option { + if let Some(message) = state.confirmed.pop_front() { + let ControlOutbound::Text { text, completion } = message else { + unreachable!("confirmed queue only contains text") + }; + state.control_messages = state.control_messages.saturating_sub(1); + state.control_bytes = state.control_bytes.saturating_sub(text.len()); + return Some(OutboundItem::Text(OutboundMessage { text, completion })); + } if let Some(message) = state.initial.pop_front() { Self::record_stream_pop(state, &message); - return Some(OutboundItem::Text(message.text)); + return Some(OutboundItem::Text(OutboundMessage { + text: message.text, + completion: None, + })); } if let Some(control) = state.control.pop_front() { return Some(match control { - ControlOutbound::Text(text) => { + ControlOutbound::Text { text, completion } => { state.control_messages = state.control_messages.saturating_sub(1); state.control_bytes = state.control_bytes.saturating_sub(text.len()); - OutboundItem::Text(text) + OutboundItem::Text(OutboundMessage { text, completion }) } ControlOutbound::Flush(flushed) => OutboundItem::Flush(flushed), }); } let message = state.regular.pop_front()?; Self::record_stream_pop(state, &message); - Some(OutboundItem::Text(message.text)) + Some(OutboundItem::Text(OutboundMessage { text: message.text, completion: None })) } fn record_stream_pop(state: &mut BoundedOutboundState, message: &RegularOutbound) { @@ -3157,7 +3267,17 @@ impl BoundedOutbound { fn close(&self) { let mut state = self.state.lock().unwrap(); state.closed = true; - state.control.retain(|item| matches!(item, ControlOutbound::Text(_))); + for message in &mut state.confirmed { + if let ControlOutbound::Text { completion: Some(completion), .. } = message { + let _ = completion.send(false); + } + } + for message in &mut state.control { + if let ControlOutbound::Text { completion: Some(completion), .. } = message { + let _ = completion.send(false); + } + } + state.control.retain(|item| matches!(item, ControlOutbound::Text { .. })); drop(state); self.changed.notify_all(); } @@ -3169,6 +3289,7 @@ impl BoundedOutbound { usage.stream.close(); } state.initial.clear(); + state.confirmed.clear(); state.control.clear(); state.regular.clear(); state.stream_usage.clear(); @@ -3199,9 +3320,11 @@ fn write_line_outbound_item( item: OutboundItem, ) -> std::io::Result<()> { match item { - OutboundItem::Text(text) => { - writer.write_all(text.as_bytes())?; - writer.write_all(b"\n") + OutboundItem::Text(message) => { + let written = + writer.write_all(message.text.as_bytes()).and_then(|()| writer.write_all(b"\n")); + message.finish(written.is_ok()); + written } OutboundItem::Flush(flushed) => { writer.flush()?; @@ -3235,11 +3358,14 @@ impl SynchronizedTcpStream { } fn try_clone(&self) -> std::io::Result { - Ok(Self { stream: self.stream.try_clone()?, write_lock: self.write_lock.clone() }) + Ok(Self { + stream: cmux_tui_process::tcp::clone_stream(&self.stream)?, + write_lock: self.write_lock.clone(), + }) } fn try_clone_raw(&self) -> std::io::Result { - self.stream.try_clone() + cmux_tui_process::tcp::clone_stream(&self.stream) } fn set_read_timeout(&self, timeout: Option) -> std::io::Result<()> { @@ -3354,6 +3480,17 @@ impl MessageSink for QueuedSink { self.outbound.push_control(text) } + fn send_control_confirmed( + &self, + text: Arc, + timeout: Duration, + ) -> std::io::Result<()> { + if self.control.is_none() { + return self.outbound.push_control(text); + } + self.outbound.push_control_confirmed(text, timeout) + } + fn send_terminal( &self, text: Arc, @@ -4623,6 +4760,199 @@ fn clamp_client_label(value: String) -> String { sanitize_window_title(&value).chars().take(64).collect() } +/// Connect to a live local session, or return `None` only when its socket is +/// absent or definitively refusing connections. +pub fn connect_existing_until( + path: &Path, + deadline: Instant, +) -> std::io::Result>> { + match transport::connect_until(path, deadline) { + Ok(stream) => Ok(Some(stream)), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound + ) => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SocketPublicationStage { + StaleProbeComplete, + Bound, +} + +#[cfg(test)] +type SocketPublicationHook = Arc; + +#[cfg(test)] +static SOCKET_PUBLICATION_HOOK: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +fn set_socket_publication_hook(hook: Option) { + *SOCKET_PUBLICATION_HOOK.get_or_init(Default::default).lock().unwrap() = hook; +} + +#[cfg(test)] +fn socket_publication_hook(path: &Path, stage: SocketPublicationStage) { + let hook = SOCKET_PUBLICATION_HOOK.get_or_init(Default::default).lock().unwrap().clone(); + if let Some(hook) = hook { + hook(path, stage); + } +} + +#[cfg(any(not(unix), test))] +fn cleanup_without_stable_identity(_path: &Path) { + // Windows does not expose a race-free socket publication identity through + // this transport. Leave the path in place; the next server performs a + // bounded liveness probe before reclaiming a stale socket. +} + +/// Identity-coupled ownership of one published local server socket. +/// +/// Unix cleanup removes only the socket inode created by the matching +/// successful publication. Platforms without a stable socket identity leave +/// cleanup to the next server's liveness probe. A failed bind or a later +/// replacement at the same path remains outside this owner's authority. +pub struct PublishedSocket { + path: PathBuf, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, +} + +impl PublishedSocket { + /// Claim a socket path immediately after a successful bind. + pub fn claim(path: PathBuf) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + + let metadata = std::fs::symlink_metadata(&path)?; + if !metadata.file_type().is_socket() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "published server path is not a socket", + )); + } + Ok(Self { path, device: metadata.dev(), inode: metadata.ino() }) + } + #[cfg(not(unix))] + { + Ok(Self { path }) + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Remove this publication without unlinking a path reused by another server. + pub fn cleanup(self) { + #[cfg(unix)] + { + let _ = self.cleanup_unix(); + } + #[cfg(not(unix))] + { + cleanup_without_stable_identity(&self.path); + } + } + + fn cleanup_while_publication_locked(self) { + #[cfg(unix)] + { + let _ = self.cleanup_unix_locked(); + } + #[cfg(not(unix))] + { + cleanup_without_stable_identity(&self.path); + } + } + + #[cfg(unix)] + fn matches(&self, path: &Path) -> std::io::Result { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + + let metadata = std::fs::symlink_metadata(path)?; + Ok(metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode) + } + + #[cfg(unix)] + fn cleanup_unix(&self) -> std::io::Result<()> { + let _publication_lock = PublicationGuard::acquire( + &self.path, + Instant::now() + LOCAL_SOCKET_CONNECT_TIMEOUT + LOCAL_SOCKET_CONNECT_TIMEOUT, + )?; + self.cleanup_unix_locked() + } + + #[cfg(unix)] + fn cleanup_unix_locked(&self) -> std::io::Result<()> { + match self.matches(&self.path) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + } + + let parent = self.path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "published server socket has no parent directory", + ) + })?; + let file_name = + self.path.file_name().map(|name| name.to_string_lossy()).unwrap_or_default(); + let quarantine_dir = loop { + let sequence = SOCKET_CLEANUP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let candidate = + parent.join(format!(".{file_name}.cleanup-{}-{sequence:x}", std::process::id())); + match std::fs::create_dir(&candidate) { + Ok(()) => { + if let Err(error) = platform::restrict_directory(&candidate) { + let _ = std::fs::remove_dir(&candidate); + return Err(error); + } + break candidate; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + }; + let quarantined = quarantine_dir.join("socket"); + if let Err(error) = std::fs::rename(&self.path, &quarantined) { + let _ = std::fs::remove_dir(&quarantine_dir); + return if error.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(error) }; + } + + match self.matches(&quarantined) { + Ok(true) => { + let removed = std::fs::remove_file(&quarantined); + let _ = std::fs::remove_dir(&quarantine_dir); + removed + } + Ok(false) | Err(_) => { + // An uncoordinated replacement won the race between the + // public identity check and rename. Restore it without + // overwriting any newer publication at the public path. + std::fs::hard_link(&quarantined, &self.path)?; + std::fs::remove_file(&quarantined)?; + std::fs::remove_dir(&quarantine_dir) + } + } + } +} + fn validate_resource_client_label( field: &'static str, value: Option, @@ -4643,9 +4973,19 @@ fn validate_resource_client_label( Ok(Some(value)) } +/// Bind the socket and serve connections on background threads. +pub fn serve(mux: Arc, path: Option) -> anyhow::Result { + serve_paused(mux, path)?.mark_ready() +} + +/// Bind the socket and return its exact publication ownership. +pub fn serve_owned(mux: Arc, path: Option) -> anyhow::Result { + Ok(serve_paused(mux, path)?.into_published_socket()) +} + /// A bound local server whose lifecycle endpoint is not ready yet. pub struct PendingServer { - path: Option, + published: Option, mux: Arc, shutdown: Arc, } @@ -4654,21 +4994,28 @@ impl PendingServer { /// Publish lifecycle readiness and transfer socket cleanup to the caller. pub fn mark_ready(mut self) -> anyhow::Result { self.mux.mark_server_lifecycle_ready(); - Ok(self.path.take().expect("pending server path is available")) + let published = self.published.take().expect("pending server publication is available"); + Ok(published.path().to_path_buf()) } - /// Transfer socket cleanup while another startup owner publishes readiness. + /// Transfer exact socket cleanup while another startup owner publishes readiness. + pub fn into_published_socket(mut self) -> PublishedSocket { + self.published.take().expect("pending server publication is available") + } + + /// Transfer the bound path while another startup owner publishes readiness. pub fn into_bound_path(mut self) -> PathBuf { - self.path.take().expect("pending server path is available") + let published = self.published.take().expect("pending server publication is available"); + published.path().to_path_buf() } } impl Drop for PendingServer { fn drop(&mut self) { - if let Some(path) = self.path.take() { + if let Some(published) = self.published.take() { self.shutdown.store(true, Ordering::Release); - let _ = transport::connect(&path); - cleanup(&path); + let _ = transport::connect(published.path()); + published.cleanup(); } } } @@ -4680,19 +5027,42 @@ pub fn serve_paused(mux: Arc, path: Option) -> anyhow::Result anyhow::bail!( + let deadline = Instant::now() + LOCAL_SOCKET_CONNECT_TIMEOUT; + match connect_existing_until(&path, deadline)? { + Some(_) => anyhow::bail!( "session socket {} is already in use (another instance running?)", path.display() ), - Err(_) => std::fs::remove_file(&path)?, + None => { + #[cfg(test)] + socket_publication_hook(&path, SocketPublicationStage::StaleProbeComplete); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } } } let listener = transport::listen(&path)?; + #[cfg(test)] + socket_publication_hook(&path, SocketPublicationStage::Bound); + let published = match PublishedSocket::claim(path.clone()) { + Ok(published) => published, + Err(error) => { + // The listener drops here. Leave the path for the next liveness + // probe rather than unlinking without a captured socket identity. + return Err(error.into()); + } + }; if let Err(error) = platform::restrict_file(&path) { - cleanup(&path); + published.cleanup_while_publication_locked(); return Err(error.into()); } let active_connections = Arc::new(AtomicU64::new(0)); @@ -4701,7 +5071,7 @@ pub fn serve_paused(mux: Arc, path: Option) -> anyhow::Result, path: Option) -> anyhow::Result, path: Option) -> anyhow::Result { - serve_paused(mux, path)?.mark_ready() +/// A running opt-in WebSocket listener. Dropping it stops accepts and closes clients. +#[derive(Default)] +struct WebSocketConnectionRegistry { + state: Mutex, + idle: Condvar, +} + +#[derive(Default)] +struct WebSocketConnectionState { + shutting_down: bool, + streams: HashMap, +} + +impl WebSocketConnectionRegistry { + fn register(&self, id: u64, stream: TcpStream) -> bool { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if state.shutting_down { + let _ = stream.shutdown(Shutdown::Both); + return false; + } + state.streams.insert(id, stream); + true + } + + fn remove(&self, id: u64) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + state.streams.remove(&id); + if state.streams.is_empty() { + self.idle.notify_all(); + } + } + + fn shutdown_until(&self, deadline: Instant) -> bool { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + state.shutting_down = true; + for stream in state.streams.values() { + let _ = stream.shutdown(Shutdown::Both); + } + while !state.streams.is_empty() { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self + .idle + .wait_timeout(state, remaining) + .unwrap_or_else(std::sync::PoisonError::into_inner); + state = next; + if timeout.timed_out() && !state.streams.is_empty() { + return false; + } + } + true + } + + fn start_shutdown(&self) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + state.shutting_down = true; + for stream in state.streams.values() { + let _ = stream.shutdown(Shutdown::Both); + } + } } -/// A running opt-in WebSocket listener. Dropping it stops accepts and closes clients. pub struct WebSocketServer { local_addr: SocketAddr, shutdown: Arc, - connections: Arc>>, + connections: Arc, thread: Option>, + thread_finished: std::sync::mpsc::Receiver<()>, + #[cfg(test)] + accept_attempts: Arc, + #[cfg(test)] + active_connections: Arc, + #[cfg(all(test, target_os = "macos"))] + accepted_stream_mode: Arc, } impl WebSocketServer { pub fn local_addr(&self) -> SocketAddr { self.local_addr } + + /// Request shutdown without waiting past `deadline` for the listener. + /// + /// Returns `Ok(false)` while the caller must retain this owner and retry. + pub fn shutdown_until(&mut self, deadline: Instant) -> anyhow::Result { + self.shutdown.store(true, Ordering::Release); + if !self.connections.shutdown_until(deadline) { + return Ok(false); + } + + if self.thread.is_none() { + return Ok(true); + } + if matches!(self.thread_finished.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)) { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Ok(false); + }; + match self.thread_finished.recv_timeout(remaining) { + Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Ok(false), + } + } + let thread = self.thread.take().expect("finished WebSocket listener retained its owner"); + thread.join().map_err(|_| anyhow::anyhow!("WebSocket listener thread panicked"))?; + Ok(true) + } } impl Drop for WebSocketServer { fn drop(&mut self) { self.shutdown.store(true, Ordering::Release); - for stream in self.connections.lock().unwrap().values() { - let _ = stream.shutdown(Shutdown::Both); - } - if let Ok(stream) = TcpStream::connect(self.local_addr) { - let _ = stream.set_nodelay(true); - } - if let Some(thread) = self.thread.take() { + self.connections.start_shutdown(); + if let Some(thread) = self.thread.take() + && thread.is_finished() + { let _ = thread.join(); } } @@ -4762,6 +5218,24 @@ pub fn serve_websocket( addr: SocketAddr, token: Option, allow_insecure_bind: bool, +) -> anyhow::Result { + serve_websocket_with_tracking_cloner( + mux, + addr, + token, + allow_insecure_bind, + Arc::new(cmux_tui_process::tcp::clone_stream), + ) +} + +type WebSocketTrackingCloner = Arc std::io::Result + Send + Sync>; + +fn serve_websocket_with_tracking_cloner( + mux: Arc, + addr: SocketAddr, + token: Option, + allow_insecure_bind: bool, + tracking_cloner: WebSocketTrackingCloner, ) -> anyhow::Result { // WebSocket has no TLS here. Remote deployments must explicitly opt in and // should put cmux-tui behind a TLS-terminating reverse proxy. @@ -4778,18 +5252,32 @@ pub fn serve_websocket( ); } } - let listener = TcpListener::bind(addr)?; + let listener = cmux_tui_process::tcp::bind_listener(addr)?; + listener.set_nonblocking(true)?; let local_addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); - let connections = Arc::new(Mutex::new(HashMap::new())); + let connections = Arc::new(WebSocketConnectionRegistry::default()); let next_connection = Arc::new(AtomicU64::new(1)); let active_connections = Arc::new(AtomicU64::new(0)); + #[cfg(test)] + let accept_attempts = Arc::new(AtomicU64::new(0)); + #[cfg(all(test, target_os = "macos"))] + let accepted_stream_mode = Arc::new(AtomicU64::new(0)); let thread_shutdown = shutdown.clone(); let thread_connections = connections.clone(); + let (thread_finished_sender, thread_finished) = std::sync::mpsc::channel(); + #[cfg(test)] + let observed_active_connections = active_connections.clone(); + #[cfg(test)] + let thread_accept_attempts = accept_attempts.clone(); + #[cfg(all(test, target_os = "macos"))] + let thread_accepted_stream_mode = accepted_stream_mode.clone(); let render_service = Arc::new(RenderService::new()); let thread = std::thread::Builder::new().name("mux-ws-server".into()).spawn(move || { while !thread_shutdown.load(Ordering::Acquire) { - let (stream, peer) = match listener.accept() { + #[cfg(test)] + thread_accept_attempts.fetch_add(1, Ordering::Release); + let (stream, peer) = match cmux_tui_process::tcp::accept_stream(&listener) { Ok(connection) => connection, Err(_) => { if thread_shutdown.load(Ordering::Acquire) { @@ -4801,6 +5289,14 @@ pub fn serve_websocket( continue; } }; + if stream.set_nonblocking(false).is_err() { + continue; + } + #[cfg(all(test, target_os = "macos"))] + thread_accepted_stream_mode.store( + if tcp_stream_is_nonblocking_for_test(&stream) { 2 } else { 1 }, + Ordering::Release, + ); if stream.set_nodelay(true).is_err() { continue; } @@ -4809,8 +5305,12 @@ pub fn serve_websocket( } let Some(permit) = claim_connection(&active_connections) else { continue }; let id = next_connection.fetch_add(1, Ordering::Relaxed); - if let Ok(tracked) = stream.try_clone() { - thread_connections.lock().unwrap().insert(id, tracked); + let tracked = match tracking_cloner(&stream) { + Ok(tracked) => tracked, + Err(_) => continue, + }; + if !thread_connections.register(id, tracked) { + continue; } let mux = mux.clone(); let token = token.clone(); @@ -4828,15 +5328,39 @@ pub fn serve_websocket( render_service, Some(permit), ); - connections.lock().unwrap().remove(&id); + connections.remove(id); }) .is_err() { - cleanup_connections.lock().unwrap().remove(&id); + cleanup_connections.remove(id); } } + thread_connections.start_shutdown(); + let _ = thread_finished_sender.send(()); })?; - Ok(WebSocketServer { local_addr, shutdown, connections, thread: Some(thread) }) + Ok(WebSocketServer { + local_addr, + shutdown, + connections, + thread: Some(thread), + thread_finished, + #[cfg(test)] + accept_attempts, + #[cfg(test)] + active_connections: observed_active_connections, + #[cfg(all(test, target_os = "macos"))] + accepted_stream_mode, + }) +} + +#[cfg(all(test, target_os = "macos"))] +fn tcp_stream_is_nonblocking_for_test(stream: &TcpStream) -> bool { + use std::os::fd::AsRawFd as _; + + // SAFETY: F_GETFL only reads flags from this live TCP descriptor. + let flags = unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }; + assert!(flags >= 0, "failed to read accepted WebSocket stream flags"); + flags & libc::O_NONBLOCK != 0 } pub fn window_title_osc(title: &str) -> Vec { @@ -4990,7 +5514,11 @@ fn handle_websocket_connection_with_permit( let mut writer_stream = writer_stream; while let Some(item) = writer_outbound.recv() { let result = match item { - OutboundItem::Text(text) => writer_stream.write_websocket_text(&text), + OutboundItem::Text(message) => { + let result = writer_stream.write_websocket_text(&message.text); + message.finish(result.is_ok()); + result + } OutboundItem::Flush(flushed) => writer_stream.flush().map(|()| { let _ = flushed.send(()); }), @@ -8733,6 +9261,7 @@ fn handle_request_with_cancellation( } let detach_self = matches!(&cmd, Command::DetachClient { client: target } if *target == client); + let shutdown = matches!(&cmd, Command::Shutdown); let shutdown_daemon = matches!(&cmd, Command::ShutdownDaemon { .. }); let response = match handle_command_with_cancellation(mux, client, cmd, writer, cancellation) { Ok(data) => Response { @@ -8758,9 +9287,16 @@ fn handle_request_with_cancellation( } }; let response_ok = response.ok; - let sent = send_response(writer, response); - // Flush the successful acknowledgement before making the owning loop - // leave, so process teardown cannot race the response writer. + let sent = if shutdown && response_ok { + serde_json::to_value(&response).is_ok_and(|value| { + writer.send_control_confirmed(&value, SHUTDOWN_ACK_WRITE_TIMEOUT).is_ok() + }) + } else { + send_response(writer, response) + }; + // Daemon handoff remains reversible until its response is queued. Full + // shutdown uses the confirmed control path above and requests process exit + // only after the writer reports that the ACK reached the socket. if shutdown_daemon && response_ok { if sent { return complete_daemon_shutdown_after_ack(mux, client, writer); @@ -8772,6 +9308,13 @@ fn handle_request_with_cancellation( disconnect_client(mux, client, true); return false; } + if shutdown && response_ok { + // Cleanup is irreversible once the registry and runtimes have been + // drained. A peer that disconnects before the best-effort ACK must + // not leave an empty, permanently fenced daemon behind. + mux.request_shutdown(); + return sent; + } sent } @@ -10809,24 +11352,49 @@ fn handle_command_with_cancellation( ) -> anyhow::Result { match cmd { Command::Identify => { + let release = ReleaseIdentity::current(PROTOCOL_VERSION); let (registry_id, generation) = mux.registry_identity(); + let shutdown_cleanup = mux.shutdown_cleanup_health(); Ok(json!({ "app": "cmux-tui", - "version": env!("CARGO_PKG_VERSION"), - "build_commit": stamped_build_commit(), - "ghostty_commit": stamped_ghostty_commit(), - "protocol": PROTOCOL_VERSION, + "version": release.version, + "build_commit": release.build_commit, + "ghostty_commit": release.ghostty_commit, + "protocol": release.protocol, "capabilities": advertised_capabilities(cfg!(unix)), "session": mux.session, "pid": std::process::id(), "registry_id": registry_id, "generation": generation, "workspace_revision": mux.with_state(|state| state.workspace_revision), - "terminal_revision": mux.terminal_registry_snapshot()?.revision, + "terminal_revision": mux.terminal_registry_revision()?, "daemon_handoff": 1, + "shutdown_cleanup": { + "pending": shutdown_cleanup.pending, + "retrying": shutdown_cleanup.retrying, + "degraded": shutdown_cleanup.degraded, + }, "lifecycle_ready": mux.server_lifecycle_ready(), })) } + Command::Ping => { + let release = ReleaseIdentity::current(PROTOCOL_VERSION); + Ok(json!({ + "ok": true, + "version": release.version, + "build_commit": release.build_commit, + "ghostty_commit": release.ghostty_commit, + "protocol": release.protocol, + })) + } + Command::Shutdown => { + if !mux.control_clients.is_unix(client) { + anyhow::bail!("shutdown is only available over the local session socket"); + } + mux.close_all_surfaces_for_shutdown() + .map_err(|error| error.context(SERVER_SHUTDOWN_INCOMPLETE_ERROR))?; + Ok(json!({})) + } Command::ShutdownDaemon { pid, generation, force } => { let actual_identity = mux.begin_daemon_handoff( client, @@ -10838,13 +11406,6 @@ fn handle_command_with_cancellation( "generation": actual_identity.generation, })) } - Command::Ping => Ok(json!({ - "ok": true, - "version": env!("CARGO_PKG_VERSION"), - "build_commit": stamped_build_commit(), - "ghostty_commit": stamped_ghostty_commit(), - "protocol": PROTOCOL_VERSION, - })), Command::SetClientInfo { name, kind, capabilities } => { let (name, kind) = mux.control_clients.set_info(client, name, kind, capabilities)?; mux.emit(MuxEvent::ClientChanged { client, name, kind }); @@ -12715,16 +13276,6 @@ fn handle_command_with_cancellation( } } -fn stamped_build_commit() -> Option<&'static str> { - option_env!("CMUX_TUI_BUILD_COMMIT") - .or(option_env!("CMUX_MUX_BUILD_COMMIT")) - .filter(|commit| !commit.is_empty()) -} - -fn stamped_ghostty_commit() -> Option<&'static str> { - option_env!("CMUX_TUI_GHOSTTY_COMMIT").filter(|commit| !commit.is_empty()) -} - fn subscribed_event_json(event: &MuxEvent) -> Value { match event { MuxEvent::SurfaceOutput(id) => json!({"event": "surface-output", "surface": id}), @@ -12925,14 +13476,559 @@ mod tests { } #[test] - fn default_socket_path_preserves_compatible_runtime_dir() { - let runtime_dir = PathBuf::from("/tmp/cmux-tui-compat"); - assert_eq!( - default_socket_path_in_runtime_dir("main", runtime_dir.clone()), - runtime_dir.join("main.sock") - ); - } - + fn websocket_listener_shutdown_does_not_depend_on_a_wakeup_connection() { + let mux = Mux::new("websocket-listener-shutdown-test", SurfaceOptions::default()); + let server = + serve_websocket(mux.clone(), "127.0.0.1:0".parse().unwrap(), None, false).unwrap(); + + let accept_deadline = Instant::now() + Duration::from_secs(1); + while server.accept_attempts.load(Ordering::Acquire) == 0 + && Instant::now() < accept_deadline + { + std::thread::yield_now(); + } + assert_ne!( + server.accept_attempts.load(Ordering::Acquire), + 0, + "WebSocket listener never attempted to accept" + ); + server.shutdown.store(true, Ordering::Release); + let deadline = Instant::now() + STREAM_DISCONNECT_POLL + STREAM_DISCONNECT_POLL; + while !server.thread.as_ref().unwrap().is_finished() && Instant::now() < deadline { + std::thread::yield_now(); + } + let stopped_without_wakeup = server.thread.as_ref().unwrap().is_finished(); + if !stopped_without_wakeup { + let _ = cmux_tui_process::tcp::connect_stream_timeout( + &server.local_addr, + Duration::from_millis(100), + ); + } + drop(server); + let _ = mux.shutdown(); + + assert!( + stopped_without_wakeup, + "WebSocket listener shutdown required a self-connect to wake accept" + ); + } + + #[test] + fn websocket_drop_does_not_join_an_unfinished_listener() { + let (release, blocked) = std::sync::mpsc::channel(); + let (worker_finished, observed_worker) = std::sync::mpsc::channel(); + let (thread_finished_sender, thread_finished) = std::sync::mpsc::channel(); + let listener_thread = std::thread::spawn(move || { + let _ = blocked.recv(); + let _ = worker_finished.send(()); + let _ = thread_finished_sender.send(()); + }); + let server = WebSocketServer { + local_addr: "127.0.0.1:0".parse().unwrap(), + shutdown: Arc::new(AtomicBool::new(false)), + connections: Arc::new(WebSocketConnectionRegistry::default()), + thread: Some(listener_thread), + thread_finished, + accept_attempts: Arc::new(AtomicU64::new(0)), + active_connections: Arc::new(AtomicU64::new(0)), + #[cfg(target_os = "macos")] + accepted_stream_mode: Arc::new(AtomicU64::new(0)), + }; + let (dropped, observed_drop) = std::sync::mpsc::channel(); + let dropper = std::thread::spawn(move || { + drop(server); + let _ = dropped.send(()); + }); + + let completed_promptly = observed_drop.recv_timeout(Duration::from_millis(100)).is_ok(); + let _ = release.send(()); + observed_worker + .recv_timeout(Duration::from_secs(1)) + .expect("synthetic listener did not finish after release"); + dropper.join().unwrap(); + + assert!(completed_promptly, "WebSocket owner drop joined an unfinished listener thread"); + } + + #[test] + fn websocket_shutdown_retains_an_unfinished_listener_for_retry() { + let (release, blocked) = std::sync::mpsc::channel(); + let (thread_finished_sender, thread_finished) = std::sync::mpsc::channel(); + let listener_thread = std::thread::spawn(move || { + let _ = blocked.recv(); + let _ = thread_finished_sender.send(()); + }); + let mut server = WebSocketServer { + local_addr: "127.0.0.1:0".parse().unwrap(), + shutdown: Arc::new(AtomicBool::new(false)), + connections: Arc::new(WebSocketConnectionRegistry::default()), + thread: Some(listener_thread), + thread_finished, + accept_attempts: Arc::new(AtomicU64::new(0)), + active_connections: Arc::new(AtomicU64::new(0)), + #[cfg(target_os = "macos")] + accepted_stream_mode: Arc::new(AtomicU64::new(0)), + }; + + let first = server.shutdown_until(Instant::now() + Duration::from_millis(25)).unwrap(); + let retained = server.thread.is_some(); + let _ = release.send(()); + let second = server.shutdown_until(Instant::now()).unwrap(); + + assert!(!first, "unfinished WebSocket listener reported complete"); + assert!(retained, "pending WebSocket shutdown discarded listener ownership"); + assert!(second, "WebSocket listener did not complete after release"); + assert!(server.thread.is_none(), "completed WebSocket listener retained its join handle"); + } + + #[test] + fn websocket_shutdown_waits_for_registered_connection_owner() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (tracked, _) = listener.accept().unwrap(); + let connections = Arc::new(WebSocketConnectionRegistry::default()); + assert!(connections.register(1, tracked)); + let (listener_finished, observed_listener_finished) = std::sync::mpsc::channel(); + let (thread_finished_sender, thread_finished) = std::sync::mpsc::channel(); + let listener_thread = std::thread::spawn(move || { + listener_finished.send(()).unwrap(); + thread_finished_sender.send(()).unwrap(); + }); + observed_listener_finished + .recv_timeout(Duration::from_secs(1)) + .expect("synthetic WebSocket listener did not finish"); + let mut server = WebSocketServer { + local_addr: "127.0.0.1:0".parse().unwrap(), + shutdown: Arc::new(AtomicBool::new(false)), + connections: connections.clone(), + thread: Some(listener_thread), + thread_finished, + accept_attempts: Arc::new(AtomicU64::new(0)), + active_connections: Arc::new(AtomicU64::new(0)), + #[cfg(target_os = "macos")] + accepted_stream_mode: Arc::new(AtomicU64::new(0)), + }; + + let first = server.shutdown_until(Instant::now() + Duration::from_millis(25)).unwrap(); + let retained = connections.state.lock().unwrap().streams.contains_key(&1); + connections.remove(1); + let second = server.shutdown_until(Instant::now() + Duration::from_secs(1)).unwrap(); + drop(client); + + assert!(!first, "registered WebSocket connection owner reported complete"); + assert!(retained, "shutdown discarded the registered connection owner"); + assert!(second, "WebSocket shutdown did not finish after owner release"); + } + + #[test] + fn websocket_worker_requires_a_registered_shutdown_owner() { + let mux = Mux::new("websocket-tracking-clone-test", SurfaceOptions::default()); + let (clone_attempted, observed_clone_attempt) = std::sync::mpsc::channel(); + let tracking_cloner: WebSocketTrackingCloner = Arc::new(move |_| { + clone_attempted.send(()).unwrap(); + Err(std::io::Error::other("injected tracking clone failure")) + }); + let mut server = serve_websocket_with_tracking_cloner( + mux.clone(), + "127.0.0.1:0".parse().unwrap(), + None, + false, + tracking_cloner, + ) + .unwrap(); + let client = cmux_tui_process::tcp::connect_stream_timeout( + &server.local_addr, + Duration::from_secs(1), + ) + .unwrap(); + observed_clone_attempt + .recv_timeout(Duration::from_secs(1)) + .expect("WebSocket listener did not attempt to register the accepted connection"); + + assert!( + server.shutdown_until(Instant::now() + Duration::from_secs(1)).unwrap(), + "WebSocket listener did not finish after rejecting the untracked connection" + ); + let active_connections = server.active_connections.load(Ordering::Acquire); + drop(client); + drop(server); + let _ = mux.shutdown(); + + assert_eq!( + active_connections, 0, + "WebSocket worker started without a registered shutdown owner" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn websocket_accept_restores_blocking_mode_before_protocol_reads() { + let mux = Mux::new("websocket-blocking-accept-test", SurfaceOptions::default()); + let server = + serve_websocket(mux.clone(), "127.0.0.1:0".parse().unwrap(), None, false).unwrap(); + let client = cmux_tui_process::tcp::connect_stream_timeout( + &server.local_addr, + Duration::from_secs(1), + ) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(1); + while server.accepted_stream_mode.load(Ordering::Acquire) == 0 && Instant::now() < deadline + { + std::thread::yield_now(); + } + let accepted_stream_mode = server.accepted_stream_mode.load(Ordering::Acquire); + drop(client); + drop(server); + let _ = mux.shutdown(); + + assert_eq!( + accepted_stream_mode, 1, + "accepted WebSocket stream remained nonblocking before protocol reads" + ); + } + + #[test] + fn default_socket_path_preserves_compatible_runtime_dir() { + let runtime_dir = PathBuf::from("/tmp/cmux-tui-compat"); + assert_eq!( + default_socket_path_in_runtime_dir("main", runtime_dir.clone()), + runtime_dir.join("main.sock") + ); + } + + #[test] + fn cleanup_without_stable_identity_preserves_a_replacement_path() { + let sequence = SOCKET_CLEANUP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let directory = std::env::temp_dir() + .join(format!("cmux-unverified-publication-{}-{sequence:x}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("server.sock"); + std::fs::write(&path, b"original").unwrap(); + std::fs::remove_file(&path).unwrap(); + std::fs::write(&path, b"replacement").unwrap(); + + cleanup_without_stable_identity(&path); + + let replacement = std::fs::read(&path); + let _ = std::fs::remove_file(&path); + std::fs::remove_dir(directory).unwrap(); + assert_eq!( + replacement.expect("unverified cleanup removed a replacement publication"), + b"replacement" + ); + } + + #[cfg(unix)] + #[test] + fn published_socket_cleanup_waits_for_publication_lock() { + let sequence = SOCKET_CLEANUP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let directory = std::env::temp_dir() + .join(format!("cmux-serialized-cleanup-{}-{sequence:x}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("server.sock"); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let published = PublishedSocket::claim(path.clone()).unwrap(); + let publication_lock = + PublicationGuard::acquire(&path, Instant::now() + Duration::from_secs(1)).unwrap(); + let (finished, observed) = std::sync::mpsc::channel(); + let cleanup = std::thread::spawn(move || { + published.cleanup(); + let _ = finished.send(()); + }); + + let completed_while_locked = observed.recv_timeout(Duration::from_millis(100)).is_ok(); + drop(publication_lock); + if !completed_while_locked { + observed + .recv_timeout(Duration::from_secs(2)) + .expect("socket cleanup did not resume after the publication lock was released"); + } + cleanup.join().unwrap(); + drop(listener); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(crate::publication::publication_lock_path(&path).unwrap()); + std::fs::remove_dir(&directory).unwrap(); + + assert!(!completed_while_locked, "socket cleanup bypassed the concurrent publication lock"); + } + + #[cfg(unix)] + #[test] + fn publication_guard_transfers_across_exec() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_PUBLICATION_GUARD_CHILD"; + const DESCRIPTOR_ENV: &str = "CMUX_TUI_TEST_PUBLICATION_GUARD_FD"; + const TEST_NAME: &str = "server::tests::publication_guard_transfers_across_exec"; + + struct TestSignal(std::fs::File); + + impl TestSignal { + fn open(path: &Path) -> Self { + Self(std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap()) + } + + fn create(path: &Path) -> Self { + use std::os::unix::ffi::OsStrExt as _; + + let path_name = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: path_name is a valid, null-terminated path owned for this call. + assert_eq!(unsafe { libc::mkfifo(path_name.as_ptr(), 0o600) }, 0); + Self::open(path) + } + + fn send(&mut self) { + use std::io::Write as _; + + self.0.write_all(b"ready").unwrap(); + } + + fn receive_until(&mut self, deadline: Instant) -> Result<(), String> { + use std::io::Read as _; + use std::os::fd::AsRawFd as _; + + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| "publication signal deadline expired".to_string())?; + let timeout = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; + let mut descriptor = + libc::pollfd { fd: self.0.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: descriptor points to one initialized pollfd for this call. + let ready = unsafe { libc::poll(&raw mut descriptor, 1, timeout) }; + if ready != 1 || descriptor.revents & libc::POLLIN == 0 { + return Err("publication signal was not received before its deadline".into()); + } + let mut byte = [0_u8; 1]; + self.0.read_exact(&mut byte).map_err(|error| error.to_string()) + } + } + + if let Some(directory) = std::env::var_os(CHILD_ENV) { + let directory = PathBuf::from(directory); + let path = directory.join("server.sock"); + let mut ready = TestSignal::open(&directory.join("ready")); + let mut release = TestSignal::open(&directory.join("release")); + let mut dropped = TestSignal::open(&directory.join("dropped")); + let mut finish = TestSignal::open(&directory.join("finish")); + let mut exiting = TestSignal::open(&directory.join("exiting")); + let descriptor = + std::env::var(DESCRIPTOR_ENV).unwrap().parse::().unwrap(); + // SAFETY: the parent passed the unique descriptor copy prepared + // for this exec child and retains no child-side alias. + let guard = unsafe { + PublicationGuard::adopt_inherited( + &path, + descriptor, + Instant::now() + Duration::from_secs(2), + ) + } + .unwrap(); + ready.send(); + release.receive_until(Instant::now() + Duration::from_secs(5)).unwrap(); + drop(guard); + dropped.send(); + finish.receive_until(Instant::now() + Duration::from_secs(5)).unwrap(); + exiting.send(); + return; + } + + let sequence = SOCKET_CLEANUP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let directory = std::env::temp_dir() + .join(format!("cmux-publication-transfer-{}-{sequence:x}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("server.sock"); + let mut ready = TestSignal::create(&directory.join("ready")); + let mut release = TestSignal::create(&directory.join("release")); + let mut dropped = TestSignal::create(&directory.join("dropped")); + let mut finish = TestSignal::create(&directory.join("finish")); + let mut exiting = TestSignal::create(&directory.join("exiting")); + let guard = + PublicationGuard::acquire(&path, Instant::now() + Duration::from_secs(1)).unwrap(); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + let descriptor = guard.inherit_into(&mut command); + command + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, &directory) + .env(DESCRIPTOR_ENV, descriptor.to_string()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + let mut child = cmux_tui_process::spawn(&mut command).unwrap(); + + let ready_result = ready.receive_until(Instant::now() + Duration::from_secs(5)); + drop(guard); + let competing = + PublicationGuard::acquire(&path, Instant::now() + Duration::from_millis(150)); + let child_retained_lock = + matches!(&competing, Err(error) if error.kind() == std::io::ErrorKind::TimedOut); + drop(competing); + + release.send(); + let dropped_result = dropped.receive_until(Instant::now() + Duration::from_secs(5)); + let reacquired = PublicationGuard::acquire(&path, Instant::now() + Duration::from_secs(1)); + let child_released_lock = reacquired.is_ok(); + drop(reacquired); + finish.send(); + let exiting_result = exiting.receive_until(Instant::now() + Duration::from_secs(5)); + let status = child.wait(); + + for marker in ["ready", "release", "dropped", "finish", "exiting"] { + let _ = std::fs::remove_file(directory.join(marker)); + } + let _ = std::fs::remove_file(crate::publication::publication_lock_path(&path).unwrap()); + std::fs::remove_dir(&directory).unwrap(); + + ready_result.unwrap(); + dropped_result.unwrap(); + exiting_result.unwrap(); + let status = status.unwrap(); + assert!(status.success(), "publication child failed: {status}"); + assert!(child_retained_lock, "parent release dropped the child's publication lock"); + assert!(child_released_lock, "child guard retained publication ownership after drop"); + } + + #[cfg(unix)] + #[test] + fn publication_lock_rejects_symlink_without_changing_target_permissions() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let sequence = SOCKET_CLEANUP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let directory = std::env::temp_dir() + .join(format!("cmux-publication-symlink-{}-{sequence:x}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let socket_path = directory.join("server.sock"); + let target = directory.join("target"); + std::fs::write(&target, b"target").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap(); + let lock_path = crate::publication::publication_lock_path(&socket_path).unwrap(); + symlink(&target, &lock_path).unwrap(); + + let result = + PublicationGuard::acquire(&socket_path, Instant::now() + Duration::from_secs(1)); + let target_mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777; + let rejected = result.is_err(); + drop(result); + std::fs::remove_file(&lock_path).unwrap(); + std::fs::remove_file(&target).unwrap(); + std::fs::remove_dir(&directory).unwrap(); + + assert!(rejected, "publication lock followed a symbolic link"); + assert_eq!(target_mode, 0o644, "publication lock changed the symlink target mode"); + } + + #[cfg(unix)] + #[test] + fn concurrent_stale_socket_reclamation_has_one_owner() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_CONCURRENT_SOCKET_RECLAMATION"; + const TEST_NAME: &str = "server::tests::concurrent_stale_socket_reclamation_has_one_owner"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "socket reclamation subprocess failed: {status}"); + return; + } + + #[derive(Default)] + struct RaceState { + second_probed: bool, + first_bound: bool, + second_bound: bool, + } + + struct ClearPublicationHook; + impl Drop for ClearPublicationHook { + fn drop(&mut self) { + set_socket_publication_hook(None); + } + } + + let sequence = SOCKET_CLEANUP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let directory = std::env::temp_dir() + .join(format!("cmux-concurrent-publication-{}-{sequence:x}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("server.sock"); + std::os::unix::net::UnixListener::bind(&path).unwrap(); + + let state = Arc::new((Mutex::new(RaceState::default()), Condvar::new())); + let hook_path = path.clone(); + let hook_state = state.clone(); + set_socket_publication_hook(Some(Arc::new(move |path, stage| { + if path != hook_path { + return; + } + let thread_name = std::thread::current().name().unwrap_or_default().to_string(); + let (state, changed) = &*hook_state; + let mut state = state.lock().unwrap(); + match (thread_name.as_str(), stage) { + ("socket-startup-second", SocketPublicationStage::StaleProbeComplete) => { + state.second_probed = true; + changed.notify_all(); + let (next, _) = changed + .wait_timeout_while(state, Duration::from_secs(1), |state| { + !state.first_bound + }) + .unwrap(); + drop(next); + } + ("socket-startup-first", SocketPublicationStage::Bound) => { + state.first_bound = true; + changed.notify_all(); + let (next, _) = changed + .wait_timeout_while(state, Duration::from_secs(1), |state| { + !state.second_bound + }) + .unwrap(); + drop(next); + } + ("socket-startup-second", SocketPublicationStage::Bound) => { + state.second_bound = true; + changed.notify_all(); + } + _ => {} + } + }))); + let _clear_hook = ClearPublicationHook; + + let second_path = path.clone(); + let second = std::thread::Builder::new() + .name("socket-startup-second".into()) + .spawn(move || serve_owned(test_mux(), Some(second_path))) + .unwrap(); + { + let (state, changed) = &*state; + let state = state.lock().unwrap(); + let (state, timeout) = changed + .wait_timeout_while(state, Duration::from_secs(2), |state| !state.second_probed) + .unwrap(); + assert!(!timeout.timed_out() && state.second_probed); + } + let first_path = path.clone(); + let first = std::thread::Builder::new() + .name("socket-startup-first".into()) + .spawn(move || serve_owned(test_mux(), Some(first_path))) + .unwrap(); + + let first = first.join().unwrap(); + let second = second.join().unwrap(); + let first_error = first.as_ref().err().map(ToString::to_string); + let second_error = second.as_ref().err().map(ToString::to_string); + let publications: Vec<_> = [first.ok(), second.ok()].into_iter().flatten().collect(); + let owners = publications.len(); + for publication in publications { + publication.cleanup(); + } + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(crate::publication::publication_lock_path(&path).unwrap()); + std::fs::remove_dir(&directory).unwrap(); + + assert_eq!( + owners, 1, + "concurrent startup created {owners} owners; first={first_error:?}, second={second_error:?}" + ); + } + #[test] fn journal_filter_rejects_secret_max_sensitivity() { let error = JournalStreamFilter::parse(Some(&json!({ @@ -13630,6 +14726,14 @@ mod tests { self.outbound.push_control(text) } + fn send_control_confirmed( + &self, + text: Arc, + _timeout: Duration, + ) -> std::io::Result<()> { + self.send_control(text) + } + fn send_terminal( &self, text: Arc, @@ -13706,6 +14810,14 @@ mod tests { self.outbound.push_control(text) } + fn send_control_confirmed( + &self, + text: Arc, + _timeout: Duration, + ) -> std::io::Result<()> { + self.send_control(text) + } + fn send_terminal( &self, text: Arc, @@ -13769,6 +14881,14 @@ mod tests { self.outbound.push_control(text) } + fn send_control_confirmed( + &self, + text: Arc, + _timeout: Duration, + ) -> std::io::Result<()> { + self.send_control(text) + } + fn send_terminal( &self, text: Arc, @@ -14065,7 +15185,7 @@ mod tests { assert!(response.get("result").is_none()); disconnect_client(&mux, client, false); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -14668,7 +15788,7 @@ mod tests { assert!(disconnect_client(&first, client, false)); drop(scheduler); drop(writer); - first.shutdown(); + let _ = first.shutdown(); let shutdown_deadline = Instant::now() + Duration::from_secs(10); while Arc::strong_count(&first) > 1 && Instant::now() < shutdown_deadline { std::thread::sleep(Duration::from_millis(10)); @@ -14701,7 +15821,7 @@ mod tests { assert_eq!(response["result"]["exited_at"], "4567890"); assert!(disconnect_client(&reopened, client, false)); - reopened.shutdown(); + let _ = reopened.shutdown(); drop(scheduler); drop(writer); drop(reopened); @@ -18270,6 +19390,34 @@ mod tests { worker.join().unwrap(); } + #[test] + fn confirmed_control_precedes_initial_stream_backlog() { + let outbound = Arc::new(BoundedOutbound::default()); + let service = RenderService::new(); + let stream = OutboundStream::new( + 1, + service.serialize_control(&json!({"event": "overflow"})).unwrap(), + ); + outbound.push_initial(service.serialize_control(&"initial").unwrap(), &stream).unwrap(); + let confirmed_text = service.serialize_control(&"ack").unwrap(); + let confirmed = { + let outbound = outbound.clone(); + std::thread::spawn(move || { + outbound.push_control_confirmed(confirmed_text, Duration::from_secs(1)) + }) + }; + let deadline = Instant::now() + Duration::from_secs(1); + while outbound.state.lock().unwrap().control_bytes == 0 && Instant::now() < deadline { + std::thread::yield_now(); + } + + let first = outbound.try_pop().unwrap(); + let second = outbound.try_pop().unwrap(); + confirmed.join().unwrap().unwrap(); + assert_eq!(first, "\"ack\"", "confirmed control waited behind initial stream state"); + assert_eq!(second, "\"initial\""); + } + #[test] fn terminal_overflow_purges_only_its_stream_and_rejects_late_frames() { let outbound = Arc::new(BoundedOutbound::default()); @@ -18441,18 +19589,19 @@ mod tests { #[test] fn identify_and_ping_return_build_metadata() { let mux = test_mux(); + let release = ReleaseIdentity::current(PROTOCOL_VERSION); let identity = handle_command(&mux, 0, Command::Identify, &test_writer()).unwrap(); assert_eq!(identity["app"].as_str(), Some("cmux-tui")); - assert_eq!(identity["version"].as_str(), Some(env!("CARGO_PKG_VERSION"))); + assert_eq!(identity["version"].as_str(), Some(release.version.as_str())); assert_eq!(identity["protocol"].as_u64(), Some(PROTOCOL_VERSION as u64)); - assert_eq!(identity["build_commit"].as_str(), stamped_build_commit()); - assert_eq!(identity["ghostty_commit"].as_str(), stamped_ghostty_commit()); + assert_eq!(identity["build_commit"].as_str(), release.build_commit.as_deref()); + assert_eq!(identity["ghostty_commit"].as_str(), release.ghostty_commit.as_deref()); let data = handle_command(&mux, 0, Command::Ping, &test_writer()).unwrap(); assert_eq!(data["ok"].as_bool(), Some(true)); - assert_eq!(data["version"].as_str(), Some(env!("CARGO_PKG_VERSION"))); - assert_eq!(data["build_commit"].as_str(), stamped_build_commit()); - assert_eq!(data["ghostty_commit"].as_str(), stamped_ghostty_commit()); + assert_eq!(data["version"].as_str(), Some(release.version.as_str())); + assert_eq!(data["build_commit"].as_str(), release.build_commit.as_deref()); + assert_eq!(data["ghostty_commit"].as_str(), release.ghostty_commit.as_deref()); assert_eq!(data["protocol"].as_u64(), Some(PROTOCOL_VERSION as u64)); assert_eq!(identity["daemon_handoff"].as_u64(), Some(1)); assert!( @@ -18741,7 +19890,7 @@ mod tests { Command::AttachSurface { surface: surface.id, mode: None, cols: None, rows: None }, &writer, ); - mux.shutdown(); + mux.shutdown().unwrap(); let error = attach.expect_err("a legacy pointer owner must not gain a guarded attach"); assert!( @@ -18750,6 +19899,209 @@ mod tests { ); } + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn repeated_identify_never_rescans_the_global_process_table() { + let mux = test_mux(); + crate::process_session::reset_stable_process_preflight_count_for_test(); + + handle_command(&mux, 0, Command::Identify, &test_writer()).unwrap(); + handle_command(&mux, 0, Command::Identify, &test_writer()).unwrap(); + + assert_eq!( + crate::process_session::stable_process_preflight_count_for_test(), + 0, + "ordinary identity requests repeated the process-control startup preflight" + ); + } + + #[test] + fn identify_reads_terminal_revision_without_materializing_the_registry() { + let mux = test_mux(); + mux.reset_terminal_snapshot_count_for_test(); + + let identity = handle_command(&mux, 0, Command::Identify, &test_writer()).unwrap(); + + assert_eq!(identity["terminal_revision"].as_u64(), Some(0)); + assert_eq!( + mux.terminal_snapshot_count_for_test(), + 0, + "identity preflight materialized every terminal record" + ); + } + + #[test] + fn identify_exposes_shutdown_cleanup_health() { + let mux = test_mux(); + let identity = handle_command(&mux, 0, Command::Identify, &test_writer()).unwrap(); + + assert_eq!(identity["shutdown_cleanup"]["pending"].as_u64(), Some(0)); + assert_eq!(identity["shutdown_cleanup"]["retrying"].as_bool(), Some(false)); + assert_eq!(identity["shutdown_cleanup"]["degraded"].as_bool(), Some(false)); + } + + #[test] + fn local_shutdown_responds_before_requesting_mux_exit() { + let mux = test_mux(); + let surface = mux.new_workspace(None, None).unwrap(); + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + let client = mux.control_clients.register(ClientTransport::Unix, writer.clone()); + + assert!(handle_message(&mux, client, r#"{"id":7,"cmd":"shutdown"}"#, &writer)); + assert!(mux.shutdown_requested()); + assert!(mux.surface(surface.id).is_none()); + let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(response, json!({"id": 7, "ok": true, "data": {}})); + } + + struct ConfirmedControlSink { + entered: std::sync::mpsc::SyncSender<()>, + release: Mutex>, + open: AtomicBool, + } + + impl MessageSink for ConfirmedControlSink { + fn send_initial( + &self, + _text: Arc, + _stream: &OutboundStream, + ) -> std::io::Result<()> { + Ok(()) + } + + fn send_stream( + &self, + _text: Arc, + _stream: &OutboundStream, + ) -> std::io::Result<()> { + Ok(()) + } + + fn send_control(&self, _text: Arc) -> std::io::Result<()> { + Ok(()) + } + + fn send_control_confirmed( + &self, + _text: Arc, + _timeout: Duration, + ) -> std::io::Result<()> { + self.entered + .send(()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test closed"))?; + self.release + .lock() + .unwrap() + .recv() + .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test closed")) + } + + fn send_terminal( + &self, + _text: Arc, + _stream: &OutboundStream, + ) -> std::io::Result<()> { + Ok(()) + } + + fn send_ordered_terminal( + &self, + _text: Arc, + _stream: &OutboundStream, + ) -> std::io::Result<()> { + Ok(()) + } + + fn is_open(&self) -> bool { + self.open.load(Ordering::Acquire) + } + + fn close(&self) { + self.open.store(false, Ordering::Release); + } + } + + #[test] + fn local_shutdown_waits_for_the_acknowledgement_write() { + let mux = test_mux(); + let surface = mux.new_workspace(None, None).unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let writer = MessageWriter::new(ConfirmedControlSink { + entered: entered_tx, + release: Mutex::new(release_rx), + open: AtomicBool::new(true), + }); + let client = mux.control_clients.register(ClientTransport::Unix, writer.clone()); + let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1); + let request = std::thread::spawn({ + let mux = mux.clone(); + move || { + done_tx + .send(handle_message(&mux, client, r#"{"id":7,"cmd":"shutdown"}"#, &writer)) + .unwrap(); + } + }); + + entered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("shutdown did not use the confirmed control path"); + assert!(!mux.shutdown_requested()); + assert!(mux.surface(surface.id).is_none()); + assert!(done_rx.recv_timeout(Duration::from_millis(50)).is_err()); + + release_tx.send(()).unwrap(); + assert!(done_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + request.join().unwrap(); + assert!(mux.shutdown_requested()); + } + + #[test] + fn local_shutdown_exits_after_cleanup_when_the_peer_drops_before_ack() { + let mux = test_mux(); + let surface = mux.new_workspace(None, None).unwrap(); + let writer = test_writer(); + let client = mux.control_clients.register(ClientTransport::Unix, writer.clone()); + writer.close(); + + assert!(!handle_message(&mux, client, r#"{"id":7,"cmd":"shutdown"}"#, &writer)); + assert!(mux.shutdown_requested()); + assert!(mux.surface(surface.id).is_none()); + } + + #[test] + fn websocket_clients_cannot_shutdown_the_server() { + let mux = test_mux(); + let writer = test_writer(); + let client = mux.control_clients.register(ClientTransport::WebSocket, writer.clone()); + + let error = handle_command(&mux, client, Command::Shutdown, &writer).unwrap_err(); + + assert!(error.to_string().contains("local session socket")); + assert!(!mux.shutdown_requested()); + } + + #[test] + fn local_shutdown_returns_a_stable_cleanup_error_code() { + let mux = test_mux(); + let surface = mux.new_workspace(None, None).unwrap(); + surface.set_server_shutdown_failure_for_test(true); + let writer = test_writer(); + let client = mux.control_clients.register(ClientTransport::Unix, writer.clone()); + + let error = handle_command(&mux, client, Command::Shutdown, &writer).unwrap_err(); + + assert_eq!(error.to_string(), SERVER_SHUTDOWN_INCOMPLETE_ERROR); + let diagnostic = format!("{error:#}"); + assert!( + diagnostic.contains("could not terminate 1 surface process") + && diagnostic.contains("key=Surface(") + && diagnostic.contains("phase=untracked-local-kill-unconfirmed"), + "shutdown diagnostic discarded its retained owner: {error:#}" + ); + } + #[test] fn split_ids_serialize_stably_and_both_ratio_commands_work() { let mux = test_mux(); @@ -18829,7 +20181,7 @@ mod tests { tree["workspaces"][0]["screens"][0]["panes"][0]["tabs"][0]["terminal_resource_id"], expected ); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -18984,7 +20336,7 @@ mod tests { let replayed_snapshot = crate::resource_api::public_session_snapshot(&mux).unwrap(); assert_eq!(replayed_snapshot["cursor"]["revision"], first_revision); assert_eq!(replayed_snapshot["terminals"].as_array().unwrap().len(), 1); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -19110,7 +20462,7 @@ mod tests { 1 ); assert!(disconnect_client(&mux, second_client, true)); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -19171,7 +20523,7 @@ mod tests { 1 ); mux.close_surface(surface).unwrap(); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -19249,7 +20601,7 @@ mod tests { assert_eq!(replay["surface"].as_u64(), Some(created)); assert!(disconnect_client(&mux, replay_client, true)); mux.close_surface(created).unwrap(); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -19339,7 +20691,7 @@ mod tests { assert_eq!(response["outcome"], "superseded"); assert_eq!(response["accepted"], false); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -19518,6 +20870,39 @@ mod tests { assert!(outbound.try_pop().is_none()); } + #[cfg(unix)] + #[test] + fn daemon_shutdown_rejects_unavailable_process_control_before_ack() { + let mux = test_mux(); + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + let local = mux.control_clients.register(ClientTransport::Unix, writer.clone()); + let (_, generation) = mux.registry_identity(); + crate::process_session::set_process_session_preflight_failure_for_test(true); + + assert!(handle_message( + &mux, + local, + &json!({ + "id": 95, + "cmd": "shutdown-daemon", + "pid": std::process::id(), + "generation": generation, + }) + .to_string(), + &writer, + )); + + crate::process_session::set_process_session_preflight_failure_for_test(false); + let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(response["ok"], false); + assert!(!mux.daemon_shutdown_requested()); + assert!( + !mux.control_clients.daemon_handoff_pending(), + "failed preflight left daemon handoff admission fenced" + ); + } + #[test] fn daemon_shutdown_force_preserves_the_identity_fence() { let mux = test_mux(); @@ -21617,6 +23002,14 @@ mod tests { ] { assert!(capabilities.iter().any(|value| value.as_str() == Some(expected))); } + #[cfg(any(target_os = "macos", target_os = "linux"))] + assert!( + capabilities.iter().any(|value| value.as_str() == Some(SERVER_SHUTDOWN_CAPABILITY)) + ); + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + assert!( + capabilities.iter().all(|value| value.as_str() != Some(SERVER_SHUTDOWN_CAPABILITY)) + ); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/spawned_pty_child.rs b/cmux-tui/crates/cmux-tui-core/src/spawned_pty_child.rs new file mode 100644 index 000000000000..f4ee734f78fc --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/spawned_pty_child.rs @@ -0,0 +1,490 @@ +//! Immediate ownership of a spawned PTY child until its reaper completes. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +#[cfg(unix)] +const FAILED_SPAWN_SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); +const PORTABLE_CHILD_REAP_CAPACITY: usize = 4_096; +const PORTABLE_CHILD_REAP_RETRY_INITIAL: Duration = Duration::from_millis(25); +const PORTABLE_CHILD_REAP_RETRY_MAX: Duration = Duration::from_secs(1); + +static PORTABLE_CHILD_REAPER: OnceLock>> = OnceLock::new(); +static STRANDED_PORTABLE_CHILDREN: OnceLock>> = OnceLock::new(); + +struct PortableChildReaper { + sender: mpsc::Sender, + active: Arc, + _worker: std::thread::JoinHandle<()>, +} + +pub(crate) struct ReservedPortableChildReaperLease { + sender: mpsc::Sender, + active: Arc, +} + +impl Drop for ReservedPortableChildReaperLease { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} + +struct PortableChildReapRequest { + child: Box, + lease: Option, + next_attempt: Instant, + retry_delay: Duration, +} + +#[cfg(test)] +static PORTABLE_REAPER_TEST_SENDER: Mutex>> = + Mutex::new(None); + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PortableReaperTestEvent { + Attempt, + Complete, +} + +#[cfg(test)] +fn publish_portable_reaper_test_event(event: PortableReaperTestEvent) { + if let Some(sender) = PORTABLE_REAPER_TEST_SENDER.lock().unwrap().as_ref() { + let _ = sender.send(event); + } +} + +struct StrandedPortableChild { + _child: Box, + _lease: Option, +} + +impl PortableChildReapRequest { + fn schedule_retry(&mut self) { + self.next_attempt = Instant::now() + self.retry_delay; + self.retry_delay = (self.retry_delay * 2).min(PORTABLE_CHILD_REAP_RETRY_MAX); + } +} + +impl PortableChildReaper { + fn start() -> std::io::Result { + let (sender, receiver) = mpsc::channel(); + let active = Arc::new(AtomicUsize::new(0)); + let worker = std::thread::Builder::new() + .name("cmux-portable-child-reaper".into()) + .spawn(move || run_portable_child_reaper(receiver))?; + Ok(Self { sender, active, _worker: worker }) + } + + fn lease(&self) -> std::io::Result { + self.active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < PORTABLE_CHILD_REAP_CAPACITY).then_some(active + 1) + }) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "portable child reaper capacity exhausted", + ) + })?; + Ok(ReservedPortableChildReaperLease { + sender: self.sender.clone(), + active: self.active.clone(), + }) + } +} + +pub(crate) fn reserve_portable_child_reaper() -> std::io::Result { + let mut slot = PORTABLE_CHILD_REAPER.get_or_init(|| Mutex::new(None)).lock().unwrap(); + if slot.is_none() { + *slot = Some(PortableChildReaper::start()?); + } + slot.as_ref().expect("portable child reaper initialized").lease() +} + +#[cfg(test)] +fn portable_child_reaper_active_for_test() -> usize { + let Some(slot) = PORTABLE_CHILD_REAPER.get() else { return 0 }; + let slot = slot.lock().unwrap(); + slot.as_ref().map_or(0, |reaper| reaper.active.load(Ordering::Acquire)) +} + +fn enqueue_portable_child_cleanup( + child: Box, + lease: ReservedPortableChildReaperLease, +) { + let sender = lease.sender.clone(); + let request = PortableChildReapRequest { + child, + lease: Some(lease), + next_attempt: Instant::now(), + retry_delay: PORTABLE_CHILD_REAP_RETRY_INITIAL, + }; + if let Err(mpsc::SendError(request)) = sender.send(request) { + retain_stranded_portable_child(request.child, request.lease); + } +} + +fn retain_stranded_portable_child( + mut child: Box, + lease: Option, +) { + // A Windows child always reserves the shared worker before spawn, so this + // path requires the already-started worker to have failed unexpectedly. + // Retain the handle without calling blocking wait; bounded capacity then + // prevents later spawns from silently compounding the ownership failure. + let _ = child.kill(); + STRANDED_PORTABLE_CHILDREN + .get_or_init(|| Mutex::new(Vec::new())) + .lock() + .unwrap() + .push(StrandedPortableChild { _child: child, _lease: lease }); +} + +fn run_portable_child_reaper(receiver: mpsc::Receiver) { + let mut pending = Vec::::new(); + loop { + let received = if pending.is_empty() { + receiver.recv().map_err(|_| RecvTimeoutError::Disconnected) + } else { + let now = Instant::now(); + let wait = pending + .iter() + .map(|request| request.next_attempt.saturating_duration_since(now)) + .min() + .unwrap_or(PORTABLE_CHILD_REAP_RETRY_MAX); + receiver.recv_timeout(wait) + }; + match received { + Ok(request) => pending.push(request), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) if pending.is_empty() => return, + Err(RecvTimeoutError::Disconnected) => { + std::thread::sleep(PORTABLE_CHILD_REAP_RETRY_MAX); + } + } + while let Ok(request) = receiver.try_recv() { + pending.push(request); + } + + let now = Instant::now(); + let mut index = 0; + while index < pending.len() { + if pending[index].next_attempt > now { + index += 1; + continue; + } + let mut request = pending.swap_remove(index); + let _ = request.child.kill(); + #[cfg(test)] + publish_portable_reaper_test_event(PortableReaperTestEvent::Attempt); + match request.child.try_wait() { + Ok(Some(_)) => { + drop(request.lease.take()); + #[cfg(test)] + publish_portable_reaper_test_event(PortableReaperTestEvent::Complete); + } + Ok(None) | Err(_) => { + request.schedule_retry(); + pending.push(request); + } + } + } + } +} + +#[cfg(unix)] +fn signal_owned_child_for_cleanup(session: libc::pid_t) -> std::io::Result<()> { + loop { + // SAFETY: callers retain the sole unreaped Child handle for `session`, + // so the numeric PID cannot be reused before the final wait. + if unsafe { libc::kill(session, libc::SIGKILL) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + if error.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + return Err(error); + } +} + +pub(crate) struct SpawnedPtyChild { + child: Option>, + #[cfg(unix)] + reaper: Option, + portable_reaper: Option, +} + +impl SpawnedPtyChild { + pub(crate) fn new(child: Box) -> Self { + Self { + child: Some(child), + #[cfg(unix)] + reaper: None, + portable_reaper: None, + } + } + + #[cfg(unix)] + pub(crate) fn with_reaper( + mut self, + reaper: crate::process_session::ReservedChildReaperLease, + ) -> Self { + assert!(self.reaper.replace(reaper).is_none(), "spawned PTY child already has a reaper"); + self + } + + #[cfg(not(unix))] + pub(crate) fn with_reaper(mut self, reaper: ReservedPortableChildReaperLease) -> Self { + assert!( + self.portable_reaper.replace(reaper).is_none(), + "spawned PTY child already has a reaper" + ); + self + } + + #[cfg(unix)] + pub(crate) fn take_reaper( + &mut self, + ) -> Option { + self.reaper.take() + } + + pub(crate) fn process_id(&self) -> Option { + self.child.as_ref().expect("spawned PTY child is present").process_id() + } + + pub(crate) fn clone_killer(&self) -> Box { + self.child.as_ref().expect("spawned PTY child is present").clone_killer() + } + + #[cfg(not(unix))] + pub(crate) fn wait(&mut self) -> std::io::Result { + let result = self.child.as_mut().expect("spawned PTY child is present").wait(); + if result.is_ok() { + self.child.take(); + } + result + } + + /// Release a child only after stable identity proves that this process no + /// longer owns its wait handle and the numeric PID belongs to no live + /// process from the original publication. + pub(crate) fn abandon_wait_ownership(mut self) { + self.child.take(); + } +} + +#[cfg(unix)] +fn enqueue_failed_spawn_cleanup( + child: Box, + reaper: crate::process_session::ReservedChildReaperLease, + session: libc::pid_t, +) { + // The unreaped Child handle reserves this numeric PID, so this raw signal + // cannot retarget a reused process. Keep the leader waitable while the + // shared reaper enumerates and removes every other session member. + let _ = signal_owned_child_for_cleanup(session); + + let (finished_sender, finished_receiver) = mpsc::sync_channel(1); + let mut finished_sender = Some(finished_sender); + let mut child = Some(child); + crate::process_session::enqueue_reserved_session_leader( + reaper, + session, + FAILED_SPAWN_SESSION_CLEANUP_TIMEOUT, + move || match crate::process_session::observe_child_without_reaping(session, true)? { + crate::process_session::ChildWaitState::Running => { + signal_owned_child_for_cleanup(session)?; + Ok(false) + } + crate::process_session::ChildWaitState::Waitable => Ok(true), + crate::process_session::ChildWaitState::OwnershipLost => { + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) + } + }, + || true, + || true, + move |cleanup_succeeded| { + if !cleanup_succeeded { + return crate::process_session::NaturalReapFinish::Pending; + } + let Some(owned_child) = child.as_mut() else { + return crate::process_session::NaturalReapFinish::Complete; + }; + match owned_child.wait() { + Ok(_) => { + child.take(); + if let Some(sender) = finished_sender.take() { + let _ = sender.send(()); + } + crate::process_session::NaturalReapFinish::Complete + } + Err(_) => crate::process_session::NaturalReapFinish::Failed, + } + }, + ); + let _ = finished_receiver + .recv_timeout(FAILED_SPAWN_SESSION_CLEANUP_TIMEOUT + Duration::from_millis(250)); +} + +impl Drop for SpawnedPtyChild { + fn drop(&mut self) { + let Some(child) = self.child.take() else { return }; + #[cfg(unix)] + if let Some(reaper) = self.reaper.take() { + if let Some(session) = + child.process_id().and_then(|pid| libc::pid_t::try_from(pid).ok()) + { + enqueue_failed_spawn_cleanup(child, reaper, session); + return; + } + drop(reaper); + } + let reaper = self.portable_reaper.take().or_else(|| reserve_portable_child_reaper().ok()); + if let Some(reaper) = reaper { + enqueue_portable_child_cleanup(child, reaper); + } else { + retain_stranded_portable_child(child, None); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + struct PortableReaperTestObserver; + + impl PortableReaperTestObserver { + fn install() -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(); + let previous = PORTABLE_REAPER_TEST_SENDER.lock().unwrap().replace(sender); + assert!(previous.is_none(), "portable reaper observer was already installed"); + (Self, receiver) + } + } + + impl Drop for PortableReaperTestObserver { + fn drop(&mut self) { + PORTABLE_REAPER_TEST_SENDER.lock().unwrap().take(); + } + } + + #[derive(Debug)] + struct FailedKiller; + + impl portable_pty::ChildKiller for FailedKiller { + fn kill(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "forced child kill failure", + )) + } + + fn clone_killer(&self) -> Box { + Box::new(Self) + } + } + + #[derive(Debug)] + struct BlockingWaitChild { + try_waits: usize, + wait_called: Arc, + } + + impl portable_pty::ChildKiller for BlockingWaitChild { + fn kill(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "forced child kill failure", + )) + } + + fn clone_killer(&self) -> Box { + Box::new(FailedKiller) + } + } + + impl portable_pty::Child for BlockingWaitChild { + fn try_wait(&mut self) -> std::io::Result> { + self.try_waits = self.try_waits.saturating_add(1); + Ok((self.try_waits >= 2).then(|| portable_pty::ExitStatus::with_exit_code(1))) + } + + fn wait(&mut self) -> std::io::Result { + self.wait_called.store(true, Ordering::Release); + loop { + std::thread::park(); + } + } + + fn process_id(&self) -> Option { + None + } + + #[cfg(windows)] + fn as_raw_handle(&self) -> Option { + None + } + } + + #[test] + fn failed_kill_does_not_block_spawned_child_drop() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_NONBLOCKING_CHILD_DROP"; + const TEST_NAME: &str = + "spawned_pty_child::tests::failed_kill_does_not_block_spawned_child_drop"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "nonblocking child-drop subprocess failed: {status}"); + return; + } + + let (_observer, events) = PortableReaperTestObserver::install(); + let wait_called = Arc::new(AtomicBool::new(false)); + let child = SpawnedPtyChild::new(Box::new(BlockingWaitChild { + try_waits: 0, + wait_called: wait_called.clone(), + })); + let (dropped_sender, dropped_receiver) = mpsc::sync_channel(1); + std::thread::spawn(move || { + drop(child); + let _ = dropped_sender.send(()); + }); + + dropped_receiver + .recv_timeout(Duration::from_millis(250)) + .expect("spawned PTY child Drop blocked after kill failed"); + assert_eq!( + events.recv_timeout(Duration::from_millis(500)).unwrap(), + PortableReaperTestEvent::Attempt + ); + assert_eq!( + events.recv_timeout(Duration::from_secs(1)).unwrap(), + PortableReaperTestEvent::Attempt + ); + assert_eq!( + events.recv_timeout(Duration::from_secs(1)).unwrap(), + PortableReaperTestEvent::Complete + ); + assert_eq!( + portable_child_reaper_active_for_test(), + 0, + "cleanup owner retained its reservation after the child exited" + ); + assert!(!wait_called.load(Ordering::Acquire), "cleanup owner called blocking child wait"); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index be42fd3996d2..261b759dfbd2 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -5,6 +5,7 @@ //! browser-aware frontends should branch on [`SurfaceKind`] before using //! VT operations. +#[cfg(unix)] use std::borrow::Cow; use std::collections::{HashMap, VecDeque}; use std::io::{Read, Write}; @@ -20,6 +21,7 @@ use std::sync::mpsc::{ use std::sync::{Arc, Condvar, Mutex, TryLockError, Weak}; use std::time::{Duration, Instant}; +use anyhow::Context; use cmux_pty::{ChildKiller, MasterPty, PtyCommand, PtySize}; use ghostty_vt::{ Callbacks, ClearHistoryOutcome, CursorShape, Dirty, KeyEncoder, KeyInput, KittyGraphicsLimits, @@ -27,19 +29,23 @@ use ghostty_vt::{ Terminal, TerminalColorOverrides, TerminalPointerSemanticSnapshot, TrackedScreenPoint, }; +#[cfg(any(unix, test))] +use crate::MuxEvent; use crate::mux::ResourceWaitWake; use crate::platform; use crate::resource::{ContentPublicId, TabResourceIdentity, TerminalPublicId}; -use crate::terminal_host_protocol::{TerminalExit, wait_for_native_child_status}; -use crate::{Mux, MuxEvent, SurfaceId}; +use crate::terminal_host_protocol::TerminalExit; +#[cfg(not(unix))] +use crate::terminal_host_protocol::TerminalExitOutcome; +use crate::{Mux, SurfaceId}; pub use crate::browser::{ BrowserAttachState, BrowserFrame, BrowserFrameStream, BrowserFrameUpdate, BrowserSource, BrowserStatus, }; use crate::browser::{ - BrowserMouseDispatch, BrowserPointerOwner, BrowserResizeWaiter, BrowserSurface, - PendingBrowserResize, + BrowserMouseDispatch, BrowserPointerOwner, BrowserResizeWaiter, BrowserRuntime, + BrowserShutdownOwner, BrowserSurface, PendingBrowserResize, }; #[cfg(all(unix, test))] use crate::terminal_host_protocol::PROTOCOL_VERSION; @@ -484,6 +490,8 @@ impl HostedFrameStager { const ATTACH_STREAM_CAPACITY: usize = 256; const ATTACH_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024; +#[cfg(unix)] +const NORMAL_EXIT_SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); // Preserve every valid upload prefix plus enough recent text while fitting // both the raw attach queue and its 32 MiB base64-encoded transport. const VT_REPLAY_TEXT_HEADROOM_BYTES: usize = 2 * 1024 * 1024; @@ -1379,9 +1387,12 @@ pub struct PtyTerminalRuntime { stream_progress: Box, mouse_encoders: Mutex>, runtime: Mutex, - /// Explicit lifecycle authority for this process. Session content may - /// survive a daemon replacement through a durable host; daemon-owned - /// auxiliaries must terminate with the backend that created them. + /// Local process ownership is separate from PTY writes so shutdown never + /// waits behind a blocked writer. + local_process: Option>, + #[cfg(unix)] + hosted_shutdown_owner: Option>, + /// Explicit lifecycle authority for this process. lifetime: PtyLifetime, supports_clear_history_key_fallback: AtomicBool, host_identity: Option, @@ -1453,7 +1464,6 @@ enum PtyRuntime { Local { writer: Box, master: Option>, - killer: Box, }, #[cfg(unix)] Hosted(Box), @@ -1461,6 +1471,68 @@ enum PtyRuntime { ExitedHosted, } +#[derive(Clone)] +enum LocalProcess { + Owned(Arc), + #[cfg(test)] + Untracked(Arc>>), +} + +impl LocalProcess { + #[cfg(test)] + fn untracked(killer: Box) -> Self { + Self::Untracked(Arc::new(Mutex::new(killer))) + } + + fn request_hangup(&self) -> bool { + match self { + Self::Owned(process) => process.request_hangup(), + #[cfg(test)] + Self::Untracked(killer) => killer.lock().unwrap().kill().is_ok(), + } + } + + #[cfg(any(not(unix), test))] + fn terminate_and_wait(&self, deadline: Instant) -> bool { + match self { + Self::Owned(process) => process.terminate_and_wait(deadline), + #[cfg(test)] + Self::Untracked(killer) => killer.lock().unwrap().kill().is_ok(), + } + } + + #[cfg(unix)] + fn session_id(&self) -> Option { + match self { + Self::Owned(process) => process.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()), + #[cfg(test)] + Self::Untracked(_) => None, + } + } + + #[cfg(unix)] + fn terminate_and_wait_from_snapshot( + &self, + deadline: Instant, + snapshot: Option<&crate::process_session::SessionProcessSnapshot>, + ) -> bool { + match self { + Self::Owned(process) => snapshot.is_some_and(|snapshot| { + process.terminate_and_wait_from_snapshot(deadline, snapshot) + }), + #[cfg(test)] + Self::Untracked(killer) => killer.lock().unwrap().kill().is_ok(), + } + } + + fn retained_shutdown_phase(&self) -> &'static str { + match self { + Self::Owned(process) => process.retained_shutdown_phase(), + #[cfg(test)] + Self::Untracked(_) => "untracked-local-kill-unconfirmed", + } + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PtyLifetime { SessionOwned, @@ -1529,199 +1601,979 @@ impl ClearHistoryFailure { } #[cfg(unix)] -struct NonblockingFdGuard { - fd: std::os::fd::RawFd, - original_flags: libc::c_int, - restored: bool, +#[derive(Clone)] +struct HostedShutdownOwner { + record: crate::terminal_host_runtime::TerminalHostRecord, + record_path: PathBuf, } -#[cfg(unix)] -impl NonblockingFdGuard { - fn install(fd: std::os::fd::RawFd) -> std::io::Result { - let original_flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; - if original_flags < 0 { - return Err(std::io::Error::last_os_error()); - } - if unsafe { libc::fcntl(fd, libc::F_SETFL, original_flags | libc::O_NONBLOCK) } < 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(Self { fd, original_flags, restored: false }) - } +pub(crate) struct SurfaceShutdownOwner { + kind: SurfaceShutdownOwnerKind, +} - fn restore(&mut self) -> std::io::Result<()> { - if self.restored { - return Ok(()); - } - if unsafe { libc::fcntl(self.fd, libc::F_SETFL, self.original_flags) } < 0 { - return Err(std::io::Error::last_os_error()); - } - self.restored = true; - Ok(()) - } +pub(crate) enum SurfaceShutdownOwnerIdentity { + Surface, + #[cfg(unix)] + Hosted { + terminal_id: String, + incarnation: String, + }, } -#[cfg(unix)] -impl Drop for NonblockingFdGuard { - fn drop(&mut self) { - let _ = self.restore(); - } +enum SurfaceShutdownOwnerKind { + Local(LocalProcess), + #[cfg(unix)] + Hosted(HostedShutdownOwner), + Browser(BrowserShutdownOwner), } -#[cfg(unix)] -fn clear_history_write_failure(error: std::io::Error, delivered: usize) -> ClearHistoryFailure { - let error = anyhow::Error::from(error); - if delivered == 0 { - ClearHistoryFailure::known_not_delivered(error) - } else { - ClearHistoryFailure::ambiguous(error) +impl SurfaceShutdownOwner { + #[cfg(any(not(unix), test))] + pub(crate) fn terminate_until(&self, deadline: Instant) -> bool { + match &self.kind { + SurfaceShutdownOwnerKind::Local(process) => process.terminate_and_wait(deadline), + #[cfg(unix)] + SurfaceShutdownOwnerKind::Hosted(owner) => { + crate::terminal_host_runtime::terminate_and_confirm_terminal_host_record( + &owner.record, + &owner.record_path, + deadline, + ) + } + SurfaceShutdownOwnerKind::Browser(owner) => owner.terminate_until(deadline), + } } -} -pub(crate) fn write_clear_history_fallback( - master: &dyn MasterPty, - writer: &mut dyn Write, - bytes: &[u8], -) -> Result<(), ClearHistoryFailure> { - if bytes.len() > CLEAR_HISTORY_FALLBACK_MAX_BYTES { - return Err(ClearHistoryFailure::known_not_delivered(anyhow::anyhow!( - "encoded clear-history fallback exceeds {CLEAR_HISTORY_FALLBACK_MAX_BYTES} bytes" - ))); + #[cfg(unix)] + pub(crate) fn local_process_session(&self) -> Option { + match &self.kind { + SurfaceShutdownOwnerKind::Local(process) => process.session_id(), + SurfaceShutdownOwnerKind::Hosted(_) | SurfaceShutdownOwnerKind::Browser(_) => None, + } } #[cfg(unix)] - if let Some(fd) = master.as_raw_fd() { - let mut nonblocking = NonblockingFdGuard::install(fd) - .map_err(|error| clear_history_write_failure(error, 0))?; - let deadline = Instant::now() + CLEAR_HISTORY_FALLBACK_WRITE_TIMEOUT; - let mut delivered = 0; - while delivered < bytes.len() { - let written = unsafe { - libc::write( - fd, - bytes[delivered..].as_ptr().cast(), - bytes.len().saturating_sub(delivered), - ) - }; - if written > 0 { - delivered = delivered.saturating_add(written as usize); - continue; - } - if written == 0 { - let error = - std::io::Error::new(std::io::ErrorKind::WriteZero, "PTY write returned zero"); - return Err(clear_history_write_failure(error, delivered)); - } - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; + pub(crate) fn terminate_until_in_batch( + &self, + deadline: Instant, + process_snapshot: Option<&crate::process_session::SessionProcessSnapshot>, + ) -> bool { + match &self.kind { + SurfaceShutdownOwnerKind::Local(process) => { + process.terminate_and_wait_from_snapshot(deadline, process_snapshot) } - if error.kind() != std::io::ErrorKind::WouldBlock { - return Err(clear_history_write_failure(error, delivered)); + SurfaceShutdownOwnerKind::Hosted(owner) => { + crate::terminal_host_runtime::terminate_and_confirm_terminal_host_record( + &owner.record, + &owner.record_path, + deadline, + ) } + SurfaceShutdownOwnerKind::Browser(owner) => owner.terminate_until(deadline), + } + } - let now = Instant::now(); - if now >= deadline { - let error = anyhow::anyhow!(CLEAR_HISTORY_FALLBACK_WRITE_TIMEOUT_ERROR); - return Err(if delivered == 0 { - ClearHistoryFailure::known_not_delivered(error) - } else { - ClearHistoryFailure::ambiguous(error) - }); - } - let remaining = deadline.saturating_duration_since(now); - let timeout_ms = remaining - .as_nanos() - .saturating_add(999_999) - .checked_div(1_000_000) - .unwrap_or(u128::MAX) - .clamp(1, i32::MAX as u128) as libc::c_int; - let mut poll_fd = libc::pollfd { fd, events: libc::POLLOUT, revents: 0 }; - let ready = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) }; - if ready > 0 { - if poll_fd.revents & libc::POLLNVAL != 0 { - let error = - std::io::Error::new(std::io::ErrorKind::BrokenPipe, "PTY fd is invalid"); - return Err(clear_history_write_failure(error, delivered)); - } - continue; - } - if ready < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; - } - return Err(clear_history_write_failure(error, delivered)); + pub(crate) fn hosted_identity(&self) -> Option<(&str, &str)> { + match &self.kind { + #[cfg(unix)] + SurfaceShutdownOwnerKind::Hosted(owner) => { + Some((&owner.record.terminal_id, &owner.record.incarnation)) } + SurfaceShutdownOwnerKind::Local(_) | SurfaceShutdownOwnerKind::Browser(_) => None, } - if let Err(error) = nonblocking.restore() { - return Err(ClearHistoryFailure::ambiguous(error.into())); + } + + pub(crate) fn browser_runtime(&self) -> Option<&Arc> { + match &self.kind { + SurfaceShutdownOwnerKind::Browser(owner) => Some(owner.runtime()), + SurfaceShutdownOwnerKind::Local(_) => None, + #[cfg(unix)] + SurfaceShutdownOwnerKind::Hosted(_) => None, } - return Ok(()); } - #[cfg(test)] - { - writer - .write_all(bytes) - .and_then(|()| writer.flush()) - .map_err(anyhow::Error::from) - .map_err(ClearHistoryFailure::ambiguous) + pub(crate) fn browser_shutdown_owner(&self) -> Option<&BrowserShutdownOwner> { + match &self.kind { + SurfaceShutdownOwnerKind::Browser(owner) => Some(owner), + SurfaceShutdownOwnerKind::Local(_) => None, + #[cfg(unix)] + SurfaceShutdownOwnerKind::Hosted(_) => None, + } } - #[cfg(not(test))] - { - let _ = writer; - Err(ClearHistoryFailure::known_not_delivered(anyhow::anyhow!( - "bounded clear-history fallback writes are unavailable for this PTY" - ))) + pub(crate) fn retained_shutdown_phase(&self) -> &'static str { + match &self.kind { + SurfaceShutdownOwnerKind::Local(process) => process.retained_shutdown_phase(), + #[cfg(unix)] + SurfaceShutdownOwnerKind::Hosted(_) => "host-termination-confirmation-unfinished", + SurfaceShutdownOwnerKind::Browser(_) => "browser-target-close-unconfirmed", + } + } + + #[cfg(unix)] + pub(crate) fn hosted( + record: crate::terminal_host_runtime::TerminalHostRecord, + record_path: PathBuf, + ) -> Self { + Self { kind: SurfaceShutdownOwnerKind::Hosted(HostedShutdownOwner { record, record_path }) } } } -pub(crate) enum ClearHistoryTransition { - Cleared(Vec), - Blocked, - EncodedFallback(Vec), - Noop, +struct LocalPtyProcess { + killer: Mutex>, + #[cfg(unix)] + pid: Option, + exited: (Mutex, Condvar), + #[cfg(unix)] + pty_drained: AtomicBool, + #[cfg(unix)] + child_waitable: AtomicBool, + #[cfg(unix)] + termination_started: AtomicBool, + #[cfg(unix)] + child_signal_lock: Mutex<()>, + #[cfg(unix)] + child_reaped: AtomicBool, + #[cfg(unix)] + child_wait_ownership_lost: AtomicBool, + #[cfg(unix)] + group_escalation_complete: AtomicBool, + #[cfg(all(unix, test))] + normal_cleanup_failures: AtomicUsize, + #[cfg(all(unix, test))] + normal_cleanup_attempts: AtomicUsize, + #[cfg(all(unix, test))] + normal_cleanup_gate: Mutex>>, } -pub(crate) fn apply_clear_history_transition( - term: &mut Terminal, - fallback_key: Option<&KeyInput>, -) -> anyhow::Result { - if term.active_screen() != Screen::Alternate { - return Ok(match term.clear_history_preserving_prompt() { - ClearHistoryOutcome::Cleared(clear) => ClearHistoryTransition::Cleared(clear), - ClearHistoryOutcome::Blocked => ClearHistoryTransition::Blocked, - ClearHistoryOutcome::Unchanged => { - anyhow::bail!(CLEAR_HISTORY_PRESERVATION_ERROR) - } - }); - } - let Some(input) = fallback_key else { - return Ok(ClearHistoryTransition::Noop); - }; - let encoded = encode_key_from_terminal(term, input)?; - Ok(ClearHistoryTransition::EncodedFallback(encoded)) +#[cfg(unix)] +type LocalChildReaperLease = crate::process_session::ReservedChildReaperLease; + +#[cfg(all(unix, test))] +static DEDICATED_LOCAL_CHILD_OBSERVER_SPAWNS: AtomicUsize = AtomicUsize::new(0); + +#[cfg(all(unix, test))] +static FAIL_NEXT_FRAME_PRODUCER_SPAWN: AtomicBool = AtomicBool::new(false); + +#[cfg(all(unix, test))] +static FAIL_NEXT_LOCAL_READER_SPAWN: AtomicBool = AtomicBool::new(false); + +#[cfg(all(unix, test))] +fn dedicated_local_child_observer_spawns_for_test() -> usize { + DEDICATED_LOCAL_CHILD_OBSERVER_SPAWNS.load(Ordering::Acquire) } -pub(crate) struct TerminalStreamProgress { - next_resource_waiter_id: AtomicU64, - state: Mutex, - changed: Condvar, +#[cfg(not(unix))] +type LocalChildReaperLease = crate::spawned_pty_child::ReservedPortableChildReaperLease; + +#[cfg(unix)] +type LocalProcessSnapshot<'a> = Option<&'a crate::process_session::SessionProcessSnapshot>; + +#[cfg(not(unix))] +type LocalProcessSnapshot = (); + +#[cfg(unix)] +fn reserve_local_child_reaper() -> std::io::Result { + crate::process_session::reserve_child_reaper() } -#[derive(Default)] -struct TerminalStreamProgressState { - revision: u64, - waiters: usize, - resource_waiters: HashMap>, - #[cfg(test)] - resource_subscriptions: u64, - clear_history_wait: Option, +#[cfg(not(unix))] +fn reserve_local_child_reaper() -> std::io::Result { + crate::spawned_pty_child::reserve_portable_child_reaper() } -struct ClearHistoryWaitState { - deadline: Instant, +#[cfg(not(unix))] +fn terminal_exit_from_portable_status( + status: std::io::Result, +) -> TerminalExit { + match status { + Ok(status) if status.signal().is_some() => { + TerminalExit::unknown(format!("numeric signal status unavailable: {status}")) + } + Ok(status) => match i32::try_from(status.exit_code()) { + Ok(code) => TerminalExit::now(TerminalExitOutcome::Exit { code }), + Err(_) => TerminalExit::unknown(format!( + "portable exit code exceeds signed 32-bit range: {}", + status.exit_code() + )), + }, + Err(error) => TerminalExit::unknown(format!("wait failed: {error}")), + } +} + +#[cfg(unix)] +fn reap_reserved_local_child_status(session: libc::pid_t) -> TerminalExit { + use std::os::unix::process::ExitStatusExt; + + loop { + let mut status = 0; + // SAFETY: the shared reaper calls this while holding the signal lock + // after WNOWAIT proved `session` is our waitable child. The retained + // child handle keeps the numeric PID reserved until this sole reap. + let waited = unsafe { libc::waitpid(session, &mut status, 0) }; + if waited == session { + return TerminalExit::from_exit_status(&std::process::ExitStatus::from_raw(status)); + } + if waited < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return TerminalExit::unknown(format!("waitpid failed: {error}")); + } + return TerminalExit::unknown("waitpid returned without the reserved child"); + } +} + +impl LocalPtyProcess { + fn new(pid: Option, killer: Box) -> Arc { + #[cfg(not(unix))] + let _ = pid; + Arc::new(Self { + killer: Mutex::new(killer), + #[cfg(unix)] + pid, + exited: (Mutex::new(false), Condvar::new()), + #[cfg(unix)] + pty_drained: AtomicBool::new(false), + #[cfg(unix)] + child_waitable: AtomicBool::new(false), + #[cfg(unix)] + termination_started: AtomicBool::new(false), + #[cfg(unix)] + child_signal_lock: Mutex::new(()), + #[cfg(unix)] + child_reaped: AtomicBool::new(false), + #[cfg(unix)] + child_wait_ownership_lost: AtomicBool::new(false), + #[cfg(unix)] + group_escalation_complete: AtomicBool::new(false), + #[cfg(all(unix, test))] + normal_cleanup_failures: AtomicUsize::new(0), + #[cfg(all(unix, test))] + normal_cleanup_attempts: AtomicUsize::new(0), + #[cfg(all(unix, test))] + normal_cleanup_gate: Mutex::new(None), + }) + } + + #[cfg(unix)] + fn spawn_reaper( + self: &Arc, + mut child: crate::spawned_pty_child::SpawnedPtyChild, + surface: Arc, + ) -> std::io::Result<()> { + let process = self.clone(); + let Some(session) = process.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "native PTY child did not expose a process ID", + )); + }; + let child_identity = match crate::process_session::StableProcessHandle::capture(session) { + Ok(Some(identity)) => identity, + Ok(None) => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "native PTY child exited before its process identity was captured", + )); + } + Err(error) => return Err(error), + }; + let reaper = child.take_reaper().ok_or_else(|| { + std::io::Error::other("spawned PTY child has no reserved session reaper") + })?; + let observe_process = process.clone(); + let cleanup_process = process.clone(); + let prepare_process = process.clone(); + let reap_process = process; + let reap_surface = surface; + let mut child = Some(child); + crate::process_session::enqueue_reserved_session_leader( + reaper.attach_owner(), + session, + NORMAL_EXIT_SESSION_CLEANUP_TIMEOUT, + move || { + let _signal = observe_process.child_signal_lock.lock().unwrap(); + let state = observe_process.observe_child_wait_state_locked(session)?; + Ok(state != crate::process_session::ChildWaitState::Running) + }, + move || { + crate::process_session::reserved_child_needs_cleanup( + crate::process_session::ReservedChildReap { + signal_lock: &cleanup_process.child_signal_lock, + pty_drained: &cleanup_process.pty_drained, + termination_started: &cleanup_process.termination_started, + cleanup_complete: &cleanup_process.group_escalation_complete, + child_reaped: &cleanup_process.child_reaped, + wait_ownership_lost: &cleanup_process.child_wait_ownership_lost, + }, + ) + }, + move || prepare_process.prepare_natural_cleanup(), + move |cleanup_succeeded| { + if reap_process.child_wait_ownership_lost.load(Ordering::Acquire) { + let _signal = reap_process.child_signal_lock.lock().unwrap(); + if !reap_process.pty_drained.load(Ordering::Acquire) { + return crate::process_session::NaturalReapFinish::Pending; + } + match child_identity.matches_current() { + Ok(false) => {} + Ok(true) | Err(_) => { + return crate::process_session::NaturalReapFinish::Failed; + } + } + child + .take() + .expect("reserved child releases lost wait ownership once") + .abandon_wait_ownership(); + if let Some(pty) = reap_surface.as_pty() { + *pty.exit.lock().unwrap() = Some(TerminalExit::unknown( + "terminal child wait ownership was lost before native status was available", + )); + } + reap_process.child_reaped.store(true, Ordering::Release); + drop(_signal); + *reap_process.exited.0.lock().unwrap() = true; + reap_process.exited.1.notify_all(); + publish_local_exit_if_ready(&reap_surface); + return crate::process_session::NaturalReapFinish::Complete; + } + let done = crate::process_session::poll_reserved_session_leader( + crate::process_session::ReservedChildReap { + signal_lock: &reap_process.child_signal_lock, + pty_drained: &reap_process.pty_drained, + termination_started: &reap_process.termination_started, + cleanup_complete: &reap_process.group_escalation_complete, + child_reaped: &reap_process.child_reaped, + wait_ownership_lost: &reap_process.child_wait_ownership_lost, + }, + cleanup_succeeded, + || { + let child = child.take().expect("reserved child is reaped once"); + let exit = reap_reserved_local_child_status(session); + child.abandon_wait_ownership(); + if let Some(pty) = reap_surface.as_pty() { + *pty.exit.lock().unwrap() = Some(exit); + } + }, + ); + if done { + *reap_process.exited.0.lock().unwrap() = true; + reap_process.exited.1.notify_all(); + publish_local_exit_if_ready(&reap_surface); + } + if done { + crate::process_session::NaturalReapFinish::Complete + } else { + crate::process_session::NaturalReapFinish::Pending + } + }, + ); + Ok(()) + } + + #[cfg(not(unix))] + fn spawn_reaper( + self: &Arc, + child: crate::spawned_pty_child::SpawnedPtyChild, + surface: Arc, + ) -> std::io::Result<()> { + let process = self.clone(); + let (child_sender, child_receiver) = + sync_channel::(1); + let observer = std::thread::Builder::new().name("surface-child".into()).spawn(move || { + let Ok(mut child) = child_receiver.recv() else { return }; + let exit = terminal_exit_from_portable_status(child.wait()); + if let Some(pty) = surface.as_pty() { + *pty.exit.lock().unwrap() = Some(exit); + } + #[cfg(windows)] + close_local_terminal_master_after_exit(&surface); + *process.exited.0.lock().unwrap() = true; + process.exited.1.notify_all(); + publish_local_exit_if_ready(&surface); + }); + if let Err(error) = observer { + return Err(error); + } + if child_sender.send(child).is_err() { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "surface child observer exited before accepting ownership", + )); + } + Ok(()) + } + + fn request_hangup(&self) -> bool { + #[cfg(unix)] + { + let _signal = self.child_signal_lock.lock().unwrap(); + if self.child_reaped.load(Ordering::Acquire) { + return true; + } + let Some(session) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + return false; + }; + if !matches!( + self.observe_child_wait_state_locked(session), + Ok(crate::process_session::ChildWaitState::Running + | crate::process_session::ChildWaitState::Waitable) + ) { + return false; + } + return self.killer.lock().unwrap().kill().is_ok(); + } + #[cfg(not(unix))] + self.killer.lock().unwrap().kill().is_ok() + } + + #[cfg(unix)] + fn wake_reserved_child_reaper(&self) { + let Some(session) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + return; + }; + crate::process_session::wake_child_reaper(session); + } + + fn mark_pty_drained(&self) { + #[cfg(unix)] + { + let changed = !self.pty_drained.swap(true, Ordering::AcqRel); + self.exited.1.notify_all(); + if changed { + self.wake_reserved_child_reaper(); + } + } + } + + fn wait_for_exit_until(&self, deadline: Instant) -> bool { + let exited = self.exited.0.lock().unwrap(); + if *exited { + return true; + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (exited, _) = + self.exited.1.wait_timeout_while(exited, remaining, |exited| !*exited).unwrap(); + *exited + } + + fn retained_shutdown_phase(&self) -> &'static str { + if *self.exited.0.lock().unwrap() { + return "local-exit-observed-owner-retained"; + } + #[cfg(unix)] + { + if self.child_wait_ownership_lost.load(Ordering::Acquire) { + return "local-child-wait-ownership-lost"; + } + let child_reaped = self.child_reaped.load(Ordering::Acquire); + let group_escalation_complete = self.group_escalation_complete.load(Ordering::Acquire); + if child_reaped && group_escalation_complete { + return "local-termination-complete-owner-retained"; + } + if child_reaped { + return "local-session-drain-unconfirmed"; + } + if group_escalation_complete { + return "local-child-reap-unconfirmed"; + } + if self.child_waitable.load(Ordering::Acquire) { + return "local-session-escalation-unconfirmed"; + } + if self.termination_started.load(Ordering::Acquire) { + return "local-child-exit-unconfirmed"; + } + } + "local-termination-not-active" + } + + #[cfg(unix)] + fn observe_child_wait_state_locked( + &self, + session: libc::pid_t, + ) -> std::io::Result { + if self.child_wait_ownership_lost.load(Ordering::Acquire) { + return Ok(crate::process_session::ChildWaitState::OwnershipLost); + } + let state = crate::process_session::observe_child_without_reaping(session, true)?; + match state { + crate::process_session::ChildWaitState::Waitable => { + self.child_waitable.store(true, Ordering::Release); + self.exited.1.notify_all(); + } + crate::process_session::ChildWaitState::OwnershipLost => { + self.child_wait_ownership_lost.store(true, Ordering::Release); + self.exited.1.notify_all(); + } + crate::process_session::ChildWaitState::Running => {} + } + Ok(state) + } + + #[cfg(unix)] + fn abandon_termination(&self) { + let changed = self.termination_started.swap(false, Ordering::AcqRel); + self.exited.1.notify_all(); + if changed { + self.wake_reserved_child_reaper(); + } + } + + #[cfg(unix)] + fn wait_for_child_waitable_until(&self, deadline: Instant) { + if self.child_waitable.load(Ordering::Acquire) { + return; + } + let state = self.exited.0.lock().unwrap(); + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { return }; + let _ = self + .exited + .1 + .wait_timeout_while(state, remaining, |_| !self.child_waitable.load(Ordering::Acquire)) + .unwrap(); + } + + #[cfg(all(unix, test))] + fn wait_for_child_reaped_until(&self, deadline: Instant) -> bool { + let state = self.exited.0.lock().unwrap(); + let (state, _) = self + .exited + .1 + .wait_timeout_while(state, deadline.saturating_duration_since(Instant::now()), |_| { + !self.child_reaped.load(Ordering::Acquire) + }) + .unwrap(); + drop(state); + self.child_reaped.load(Ordering::Acquire) + } + + #[cfg(all(unix, test))] + fn wait_for_normal_cleanup_attempts_until(&self, expected: usize, deadline: Instant) -> bool { + let state = self.exited.0.lock().unwrap(); + let (state, _) = self + .exited + .1 + .wait_timeout_while(state, deadline.saturating_duration_since(Instant::now()), |_| { + self.normal_cleanup_attempts.load(Ordering::Acquire) < expected + }) + .unwrap(); + drop(state); + self.normal_cleanup_attempts.load(Ordering::Acquire) >= expected + } + + #[cfg(unix)] + fn signal_terminal_process_session( + &self, + signal: libc::c_int, + deadline: Instant, + snapshot: Option<&crate::process_session::SessionProcessSnapshot>, + ) -> std::io::Result<()> { + let _signal = self.child_signal_lock.lock().unwrap(); + let Some(session) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + return Err(std::io::Error::other("PTY child has no process id")); + }; + if self.child_wait_ownership_lost.load(Ordering::Acquire) { + return Err(std::io::Error::other("PTY child wait ownership was lost")); + } + if self.child_reaped.load(Ordering::Acquire) { + return match crate::process_session::session_is_empty_until(session, deadline) { + Ok(true) => Ok(()), + Ok(false) => Err(std::io::Error::other( + "PTY session still has members after its leader was reaped", + )), + Err(error) => Err(error), + }; + } + if self.observe_child_wait_state_locked(session)? + == crate::process_session::ChildWaitState::OwnershipLost + { + return Err(std::io::Error::other("PTY child wait ownership was lost")); + } + match snapshot { + Some(snapshot) => { + crate::process_session::signal_from_snapshot(snapshot, session, signal) + } + None => crate::process_session::signal_until(session, signal, deadline), + } + } + + #[cfg(unix)] + fn kill_terminal_process_session_until( + &self, + deadline: Instant, + snapshot: Option<&crate::process_session::SessionProcessSnapshot>, + ) -> std::io::Result { + let _signal = self.child_signal_lock.lock().unwrap(); + let Some(leader) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + return Err(std::io::Error::other("PTY child has no process id")); + }; + if self.child_wait_ownership_lost.load(Ordering::Acquire) { + return Err(std::io::Error::other("PTY child wait ownership was lost")); + } + if self.child_reaped.load(Ordering::Acquire) { + return crate::process_session::session_is_empty_until(leader, deadline); + } + if self.observe_child_wait_state_locked(leader)? + == crate::process_session::ChildWaitState::OwnershipLost + { + return Err(std::io::Error::other("PTY child wait ownership was lost")); + } + match snapshot { + Some(snapshot) => crate::process_session::kill_until_only_leader_from_snapshot( + snapshot, + leader, + leader, + deadline, + || match self.observe_child_wait_state_locked(leader)? { + crate::process_session::ChildWaitState::Running => Ok(false), + crate::process_session::ChildWaitState::Waitable => Ok(true), + crate::process_session::ChildWaitState::OwnershipLost => { + Err(std::io::Error::other("PTY child wait ownership was lost")) + } + }, + ), + None => { + crate::process_session::kill_until_only_leader(leader, leader, deadline, || { + match self.observe_child_wait_state_locked(leader)? { + crate::process_session::ChildWaitState::Running => Ok(false), + crate::process_session::ChildWaitState::Waitable => Ok(true), + crate::process_session::ChildWaitState::OwnershipLost => { + Err(std::io::Error::other("PTY child wait ownership was lost")) + } + } + }) + } + } + } + + #[cfg(unix)] + fn prepare_natural_cleanup(&self) -> bool { + #[cfg(test)] + { + self.normal_cleanup_attempts.fetch_add(1, Ordering::AcqRel); + self.exited.1.notify_all(); + if let Some(gate) = self.normal_cleanup_gate.lock().unwrap().clone() { + gate.enter_and_wait(); + } + if self + .normal_cleanup_failures + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return false; + } + } + true + } + + #[cfg(any(not(unix), test))] + fn terminate_and_wait(&self, deadline: Instant) -> bool { + #[cfg(unix)] + let snapshot = None; + #[cfg(not(unix))] + let snapshot = (); + self.terminate_and_wait_inner(deadline, snapshot) + } + + #[cfg(unix)] + fn terminate_and_wait_from_snapshot( + &self, + deadline: Instant, + snapshot: &crate::process_session::SessionProcessSnapshot, + ) -> bool { + self.terminate_and_wait_inner(deadline, Some(snapshot)) + } + + fn terminate_and_wait_inner( + &self, + deadline: Instant, + #[cfg(unix)] snapshot: LocalProcessSnapshot<'_>, + #[cfg(not(unix))] snapshot: LocalProcessSnapshot, + ) -> bool { + #[cfg(unix)] + { + let (termination_changed, child_reaped, wait_ownership_lost) = { + let _signal = self.child_signal_lock.lock().unwrap(); + let termination_changed = !self.termination_started.swap(true, Ordering::AcqRel); + ( + termination_changed, + self.child_reaped.load(Ordering::Acquire), + self.child_wait_ownership_lost.load(Ordering::Acquire), + ) + }; + self.exited.1.notify_all(); + if termination_changed { + self.wake_reserved_child_reaper(); + } + if wait_ownership_lost { + self.abandon_termination(); + return self.wait_for_exit_until(deadline); + } + if child_reaped { + if self.group_escalation_complete.load(Ordering::Acquire) { + return true; + } + let Some(session) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + self.abandon_termination(); + return false; + }; + let empty = matches!( + crate::process_session::session_is_empty_until(session, deadline), + Ok(true) + ); + if !empty { + self.abandon_termination(); + } + return empty; + } + if self.signal_terminal_process_session(libc::SIGHUP, deadline, snapshot).is_err() { + self.abandon_termination(); + return false; + } + let _ = self.request_hangup(); + self.wait_for_child_waitable_until( + deadline.min(Instant::now() + Duration::from_millis(250)), + ); + // Process groups are transient job-control details. Repeatedly + // kill every member of the owned PTY session while its leader is + // reserved with WNOWAIT, and do not acknowledge shutdown until no + // background group remains. + if !matches!(self.kill_terminal_process_session_until(deadline, snapshot), Ok(true)) { + self.abandon_termination(); + return false; + } + let escalation_changed = !self.group_escalation_complete.swap(true, Ordering::AcqRel); + self.exited.1.notify_all(); + if escalation_changed { + self.wake_reserved_child_reaper(); + } + } + #[cfg(not(unix))] + { + let _ = snapshot; + let _ = self.request_hangup(); + } + self.wait_for_exit_until(deadline) + } +} + +impl Drop for LocalPtyProcess { + fn drop(&mut self) { + #[cfg(unix)] + if !*self.child_reaped.get_mut() && !*self.child_wait_ownership_lost.get_mut() { + let _ = self.killer.get_mut().map(|killer| killer.kill()); + } + #[cfg(not(unix))] + let _ = self.killer.get_mut().map(|killer| killer.kill()); + } +} + +#[cfg(unix)] +struct NonblockingFdGuard { + fd: std::os::fd::RawFd, + original_flags: libc::c_int, + restored: bool, +} + +#[cfg(unix)] +impl NonblockingFdGuard { + fn install(fd: std::os::fd::RawFd) -> std::io::Result { + let original_flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if original_flags < 0 { + return Err(std::io::Error::last_os_error()); + } + if unsafe { libc::fcntl(fd, libc::F_SETFL, original_flags | libc::O_NONBLOCK) } < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(Self { fd, original_flags, restored: false }) + } + + fn restore(&mut self) -> std::io::Result<()> { + if self.restored { + return Ok(()); + } + if unsafe { libc::fcntl(self.fd, libc::F_SETFL, self.original_flags) } < 0 { + return Err(std::io::Error::last_os_error()); + } + self.restored = true; + Ok(()) + } +} + +#[cfg(unix)] +impl Drop for NonblockingFdGuard { + fn drop(&mut self) { + let _ = self.restore(); + } +} + +#[cfg(all(unix, test))] +fn local_child_is_waitable(pid: libc::pid_t) -> std::io::Result { + crate::process_session::observe_child_without_reaping(pid, true) + .map(|state| state == crate::process_session::ChildWaitState::Waitable) +} + +#[cfg(unix)] +fn clear_history_write_failure(error: std::io::Error, delivered: usize) -> ClearHistoryFailure { + let error = anyhow::Error::from(error); + if delivered == 0 { + ClearHistoryFailure::known_not_delivered(error) + } else { + ClearHistoryFailure::ambiguous(error) + } +} + +pub(crate) fn write_clear_history_fallback( + master: &dyn MasterPty, + writer: &mut dyn Write, + bytes: &[u8], +) -> Result<(), ClearHistoryFailure> { + if bytes.len() > CLEAR_HISTORY_FALLBACK_MAX_BYTES { + return Err(ClearHistoryFailure::known_not_delivered(anyhow::anyhow!( + "encoded clear-history fallback exceeds {CLEAR_HISTORY_FALLBACK_MAX_BYTES} bytes" + ))); + } + + #[cfg(unix)] + if let Some(fd) = master.as_raw_fd() { + let mut nonblocking = NonblockingFdGuard::install(fd) + .map_err(|error| clear_history_write_failure(error, 0))?; + let deadline = Instant::now() + CLEAR_HISTORY_FALLBACK_WRITE_TIMEOUT; + let mut delivered = 0; + while delivered < bytes.len() { + let written = unsafe { + libc::write( + fd, + bytes[delivered..].as_ptr().cast(), + bytes.len().saturating_sub(delivered), + ) + }; + if written > 0 { + delivered = delivered.saturating_add(written as usize); + continue; + } + if written == 0 { + let error = + std::io::Error::new(std::io::ErrorKind::WriteZero, "PTY write returned zero"); + return Err(clear_history_write_failure(error, delivered)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + if error.kind() != std::io::ErrorKind::WouldBlock { + return Err(clear_history_write_failure(error, delivered)); + } + + let now = Instant::now(); + if now >= deadline { + let error = anyhow::anyhow!(CLEAR_HISTORY_FALLBACK_WRITE_TIMEOUT_ERROR); + return Err(if delivered == 0 { + ClearHistoryFailure::known_not_delivered(error) + } else { + ClearHistoryFailure::ambiguous(error) + }); + } + let remaining = deadline.saturating_duration_since(now); + let timeout_ms = remaining + .as_nanos() + .saturating_add(999_999) + .checked_div(1_000_000) + .unwrap_or(u128::MAX) + .clamp(1, i32::MAX as u128) as libc::c_int; + let mut poll_fd = libc::pollfd { fd, events: libc::POLLOUT, revents: 0 }; + let ready = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) }; + if ready > 0 { + if poll_fd.revents & libc::POLLNVAL != 0 { + let error = + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "PTY fd is invalid"); + return Err(clear_history_write_failure(error, delivered)); + } + continue; + } + if ready < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(clear_history_write_failure(error, delivered)); + } + } + if let Err(error) = nonblocking.restore() { + return Err(ClearHistoryFailure::ambiguous(error.into())); + } + return Ok(()); + } + + #[cfg(test)] + { + writer + .write_all(bytes) + .and_then(|()| writer.flush()) + .map_err(anyhow::Error::from) + .map_err(ClearHistoryFailure::ambiguous) + } + + #[cfg(not(test))] + { + let _ = writer; + Err(ClearHistoryFailure::known_not_delivered(anyhow::anyhow!( + "bounded clear-history fallback writes are unavailable for this PTY" + ))) + } +} + +pub(crate) enum ClearHistoryTransition { + Cleared(Vec), + Blocked, + EncodedFallback(Vec), + Noop, +} + +pub(crate) fn apply_clear_history_transition( + term: &mut Terminal, + fallback_key: Option<&KeyInput>, +) -> anyhow::Result { + if term.active_screen() != Screen::Alternate { + return Ok(match term.clear_history_preserving_prompt() { + ClearHistoryOutcome::Cleared(clear) => ClearHistoryTransition::Cleared(clear), + ClearHistoryOutcome::Blocked => ClearHistoryTransition::Blocked, + ClearHistoryOutcome::Unchanged => { + anyhow::bail!(CLEAR_HISTORY_PRESERVATION_ERROR) + } + }); + } + let Some(input) = fallback_key else { + return Ok(ClearHistoryTransition::Noop); + }; + let encoded = encode_key_from_terminal(term, input)?; + Ok(ClearHistoryTransition::EncodedFallback(encoded)) +} + +pub(crate) struct TerminalStreamProgress { + next_resource_waiter_id: AtomicU64, + state: Mutex, + changed: Condvar, +} + +#[derive(Default)] +struct TerminalStreamProgressState { + revision: u64, + waiters: usize, + resource_waiters: HashMap>, + #[cfg(test)] + resource_subscriptions: u64, + clear_history_wait: Option, +} + +struct ClearHistoryWaitState { + deadline: Instant, revision: u64, // Timed-out waits leave this state latched at zero only while the stream // revision is unchanged. Queued repeats then fail without restarting the @@ -1979,9 +2831,6 @@ fn close_local_terminal_master_after_exit(surface: &Arc) { drop(master); } -#[cfg(not(windows))] -fn close_local_terminal_master_after_exit(_surface: &Arc) {} - fn terminal_public_id_from_resource_identity( identity: &TabResourceIdentity, invalid_context: &str, @@ -2121,7 +2970,8 @@ impl Surface { id: SurfaceId, opts: SurfaceOptions, mux: Weak, - terminal_id: Option, + terminal_id: crate::terminal_host::TerminalId, + publication_guard: crate::PublicationGuard, cell_pixels: (u16, u16), ) -> anyhow::Result> { let identity = Some(TabResourceIdentity::terminal(None)?); @@ -2129,7 +2979,7 @@ impl Surface { id, opts, mux, - terminal_id, + Some((terminal_id, publication_guard)), identity, PtyLifetime::SessionOwned, cell_pixels, @@ -2160,7 +3010,10 @@ impl Surface { id: SurfaceId, mut opts: SurfaceOptions, mux: Weak, - terminal_id: Option, + terminal_host_ownership: Option<( + crate::terminal_host::TerminalId, + crate::PublicationGuard, + )>, resource_identity: Option, lifetime: PtyLifetime, cell_pixels: (u16, u16), @@ -2192,27 +3045,32 @@ impl Surface { .map(crate::mux::KittyImageBudgetReservation::initial_limits) .unwrap_or_default(); #[cfg(unix)] + crate::process_session::require_cached_stable_process_signaling()?; + #[cfg(unix)] if lifetime == PtyLifetime::SessionOwned && let Some(root) = opts.terminal_host_root.clone() { let default_colors = mux.upgrade().map(|mux| mux.default_colors()).unwrap_or_default(); - let attachment = match terminal_id { - Some(terminal_id) => { - crate::terminal_host_runtime::launch_terminal_host_with_identity( + let launch_cancelled = || mux.upgrade().is_none_or(|mux| mux.is_shutting_down()); + let attachment = match terminal_host_ownership { + Some((terminal_id, publication_guard)) => { + crate::terminal_host_runtime::launch_terminal_host_with_identity_cancellable( &opts, &root, default_colors, cell_pixels, initial_kitty_limits, - terminal_id, + (terminal_id, publication_guard), + &launch_cancelled, )? } - None => crate::terminal_host_runtime::launch_terminal_host( + None => crate::terminal_host_runtime::launch_terminal_host_cancellable( &opts, &root, default_colors, cell_pixels, initial_kitty_limits, + &launch_cancelled, )?, }; let defer_launch_activation = terminal_public_id.is_some(); @@ -2231,7 +3089,8 @@ impl Surface { }, ); } - let _ = terminal_id; + let _ = terminal_host_ownership; + let reaper = reserve_local_child_reaper().context("reserve bounded PTY session cleanup")?; let initial_geometry = PtyGeometry { cols: opts.cols, rows: opts.rows, @@ -2251,6 +3110,7 @@ impl Surface { for (k, v) in &opts.extra_env { cmd.env(k, v); } + cmd.env_remove(crate::release::LAUNCHER_COMMAND_ENV); let cwd = opts .cwd .clone() @@ -2259,9 +3119,14 @@ impl Surface { cmd.cwd(cwd); } - let cmux_pty::SpawnedPty { master, mut child } = pty.spawn(cmd)?; + let cmux_pty::SpawnedPty { master, child } = pty.spawn(cmd)?; + let child = crate::spawned_pty_child::SpawnedPtyChild::new(child).with_reaper(reaper); let pid = child.process_id(); let killer = child.clone_killer(); + let process = LocalPtyProcess::new(pid, killer); + #[cfg(all(test, unix))] + crate::process_session::fail_after_pty_spawn_for_test()?; + let reader_process = process.clone(); #[cfg(unix)] let supports_clear_history_key_fallback = master.as_raw_fd().is_some(); #[cfg(not(unix))] @@ -2336,7 +3201,10 @@ impl Surface { term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), mouse_encoders: Mutex::new(Box::new(mouse_encoders)), - runtime: Mutex::new(PtyRuntime::Local { writer, master: Some(master), killer }), + runtime: Mutex::new(PtyRuntime::Local { writer, master: Some(master) }), + local_process: Some(Box::new(LocalProcess::Owned(process.clone()))), + #[cfg(unix)] + hosted_shutdown_owner: None, lifetime, supports_clear_history_key_fallback: AtomicBool::new( supports_clear_history_key_fallback, @@ -2397,128 +3265,118 @@ impl Surface { spawn_frame_producer(&surface, frame_rx)?; // PTY reader: pty bytes -> terminal state -> SurfaceOutput events. - let reader_thread = - std::thread::Builder::new().name(format!("surface-{id}-reader")).spawn({ - let surface = surface.clone(); - move || { - let _reader_completion = ReaderCompletionGuard( - surface - .as_pty() - .expect("local PTY reader owns a PTY surface") - .reader_completion - .clone(), - ); - let mut buf = [0u8; 64 * 1024]; - loop { - let pty = surface.as_pty().expect("surface reader got non-pty surface"); - let journal_target = pty.journal_target(); - // Reserve the capture epoch before read(2). Shutdown - // can revoke a blocked reservation, but it cannot place - // its final barrier in the read-to-parser gap. - let mut journal_update = journal_target - .as_ref() - .and_then(|_| pty.begin_terminal_journal_update()); - if journal_target.is_some() && journal_update.is_none() { + let reader_thread = spawn_local_pty_reader(id, { + let surface = surface.clone(); + move || { + let _reader_completion = ReaderCompletionGuard( + surface + .as_pty() + .expect("local PTY reader owns a PTY surface") + .reader_completion + .clone(), + ); + let mut buf = [0u8; 64 * 1024]; + loop { + let pty = surface.as_pty().expect("surface reader got non-pty surface"); + let journal_target = pty.journal_target(); + // Reserve the capture epoch before read(2). Shutdown can + // revoke a blocked reservation, but it cannot place its + // final barrier in the read-to-parser gap. + let mut journal_update = + journal_target.as_ref().and_then(|_| pty.begin_terminal_journal_update()); + if journal_target.is_some() && journal_update.is_none() { + break; + } + let n = match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => { + continue; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => break, + }; + let mut scroll_changed = None; + let generation = { + let mut term = pty.term.lock().unwrap(); + if let Some(update) = journal_update.as_mut() + && !update.activate() + { break; } - let n = match reader.read(&mut buf) { - Ok(0) => break, - Ok(n) => n, - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::Interrupted - | std::io::ErrorKind::WouldBlock - ) => - { - std::thread::sleep(Duration::from_millis(1)); - continue; - } - Err(_) => break, - }; - let mut scroll_changed = None; - let generation = { - let mut term = pty.term.lock().unwrap(); - if let Some(update) = journal_update.as_mut() - && !update.activate() - { - break; - } - let journal_enabled = journal_update.is_some(); - let before = terminal_scroll_position(&term); - let color_revision = term.color_revision(); - let color_reapply_revision = term.color_reapply_revision(); - let cursor_activity = term - .cursor_activity() - .expect("valid local terminals expose cursor activity"); - let normalized = term.vt_write_with_normalized(&buf[..n]); - let cursor_changed = term - .cursor_activity() - .expect("valid local terminals expose cursor activity") - != cursor_activity; - pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); - let after = terminal_scroll_position(&term); - let has_attach_taps = pty.broadcast_attach_output(normalized.as_ref()); - if has_attach_taps - && (term.color_revision() != color_revision || cursor_changed) + let journal_enabled = journal_update.is_some(); + let before = terminal_scroll_position(&term); + let color_revision = term.color_revision(); + let color_reapply_revision = term.color_reapply_revision(); + let cursor_activity = term + .cursor_activity() + .expect("valid local terminals expose cursor activity"); + let normalized = term.vt_write_with_normalized(&buf[..n]); + let cursor_changed = term + .cursor_activity() + .expect("valid local terminals expose cursor activity") + != cursor_activity; + pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); + let after = terminal_scroll_position(&term); + let has_attach_taps = pty.broadcast_attach_output(normalized.as_ref()); + if has_attach_taps + && (term.color_revision() != color_revision || cursor_changed) + { + pty.attach_colors_pending.store(true, Ordering::Release); + if term.color_reapply_revision() != color_reapply_revision + || cursor_changed { - pty.attach_colors_pending.store(true, Ordering::Release); - if term.color_reapply_revision() != color_reapply_revision - || cursor_changed - { - pty.attach_colors_force_pending.store(true, Ordering::Release); - } + pty.attach_colors_force_pending.store(true, Ordering::Release); } - if title_changed.swap(false, Ordering::Relaxed) { - let title = term.title().unwrap_or_default(); - *pty.title.lock().unwrap() = title.clone(); - if let Some(mux) = mux.upgrade() { - mux.emit_terminal_title(surface.id, title.into()); - } - } - if let Some(pwd) = term.pwd() { - *pty.pwd.lock().unwrap() = Some(pwd); - } - if before != after { - scroll_changed = Some(after); - broadcast_render_scroll_locked(pty, after); + } + if title_changed.swap(false, Ordering::Relaxed) { + let title = term.title().unwrap_or_default(); + *pty.title.lock().unwrap() = title.clone(); + if let Some(mux) = mux.upgrade() { + mux.emit_terminal_title(surface.id, title.into()); } - // Keep the terminal lock scoped to parser and observer work. A - // borrowed normalized frame still points into `buf`, which lives - // for the reader loop, so any journal allocation can happen after - // releasing the lock. - let journal_output = journal_enabled.then_some(normalized); - ( - pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1, - journal_output, - ) - }; - let (generation, journal_output) = generation; - if let (Some(journal_target), Some(journal_output)) = - (journal_target, journal_output) - { - pty.journal_output_if_open(journal_target, journal_output.into_owned()); } - drop(journal_update); - pty.stream_progress.notify(); - pty.request_frame(generation); - if let Some((offset, at_bottom)) = scroll_changed - && let Some(mux) = mux.upgrade() - { - mux.emit_terminal_scroll(surface.id, offset, at_bottom); + if let Some(pwd) = term.pwd() { + *pty.pwd.lock().unwrap() = Some(pwd); } - let responses = std::mem::take(&mut *pending_responses.lock().unwrap()); - if !responses.is_empty() { - let _ = surface.write_bytes(&responses); + if before != after { + scroll_changed = Some(after); + broadcast_render_scroll_locked(pty, after); } + // Keep the terminal lock scoped to parser and observer work. A + // borrowed normalized frame still points into `buf`, which lives + // for the reader loop, so any journal allocation can happen after + // releasing the lock. + let journal_output = journal_enabled.then_some(normalized); + (pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1, journal_output) + }; + let (generation, journal_output) = generation; + if let (Some(journal_target), Some(journal_output)) = + (journal_target, journal_output) + { + pty.journal_output_if_open(journal_target, journal_output.into_owned()); + } + drop(journal_update); + pty.stream_progress.notify(); + pty.request_frame(generation); + if let Some((offset, at_bottom)) = scroll_changed + && let Some(mux) = mux.upgrade() + { + mux.emit_terminal_scroll(surface.id, offset, at_bottom); } - if let Some(pty) = surface.as_pty() { - pty.publish_final_frame(); - pty.local_pty_drained.store(true, Ordering::Release); + let responses = std::mem::take(&mut *pending_responses.lock().unwrap()); + if !responses.is_empty() { + let _ = surface.write_bytes(&responses); } - publish_local_exit_if_ready(&surface); } - })?; + if let Some(pty) = surface.as_pty() { + pty.publish_final_frame(); + pty.local_pty_drained.store(true, Ordering::Release); + } + reader_process.mark_pty_drained(); + publish_local_exit_if_ready(&surface); + } + })?; *surface .as_pty() .expect("local PTY surface owns its reader") @@ -2526,20 +3384,10 @@ impl Surface { .lock() .unwrap() = Some(reader_thread); - // Child reaper: retain the native status and rendezvous with PTY EOF - // so final output is visible before the mux observes completion. - std::thread::Builder::new().name(format!("surface-{id}-wait")).spawn({ - let surface = surface.clone(); - move || { - let exit = wait_for_native_child_status(child.as_mut()); - if let Some(pty) = surface.as_pty() { - *pty.exit.lock().unwrap() = Some(exit); - } - close_local_terminal_master_after_exit(&surface); - publish_local_exit_if_ready(&surface); - } - })?; - + #[cfg(unix)] + process.spawn_reaper(child, surface.clone())?; + #[cfg(not(unix))] + process.spawn_reaper(child, surface.clone())?; Ok(surface) } @@ -2772,6 +3620,7 @@ impl Surface { let sequence_boundary = snapshot.sequence_boundary; let protocol_version = attachment.protocol_version(); let host_identity = attachment.identity(); + let (host_record, host_record_path) = attachment.discovery_record(); let mux_owner = mux.upgrade().ok_or_else(|| anyhow::anyhow!("terminal host has no mux owner"))?; let pending_host_binding = @@ -2809,6 +3658,11 @@ impl Surface { stream_progress: Box::new(TerminalStreamProgress::default()), mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::Hosted(Box::new(attachment))), + local_process: None, + hosted_shutdown_owner: Some(Box::new(HostedShutdownOwner { + record: host_record, + record_path: host_record_path, + })), lifetime, supports_clear_history_key_fallback: AtomicBool::new( supports_clear_history_key_fallback, @@ -3849,6 +4703,8 @@ impl Surface { stream_progress: Box::new(TerminalStreamProgress::default()), mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::ExitedHosted), + local_process: None, + hosted_shutdown_owner: None, lifetime: PtyLifetime::SessionOwned, supports_clear_history_key_fallback: AtomicBool::new(false), host_identity: Some(identity), @@ -3998,6 +4854,47 @@ impl Surface { resource_identity: Option, lifetime: PtyLifetime, cell_pixels: (u16, u16), + ) -> anyhow::Result> { + Self::spawn_for_test_with_lifetime_and_host_identity_at_cell_pixels( + id, + opts, + mux, + resource_identity, + lifetime, + cell_pixels, + None, + ) + } + + #[cfg(all(test, unix))] + pub(crate) fn spawn_for_test_with_terminal_host_identity( + id: SurfaceId, + opts: SurfaceOptions, + mux: Weak, + host_identity: crate::terminal_host_runtime::TerminalHostIdentity, + ) -> anyhow::Result> { + let cell_pixels = + mux.upgrade().map(|mux| mux.cell_pixel_creation_size()).unwrap_or((8, 16)); + Self::spawn_for_test_with_lifetime_and_host_identity_at_cell_pixels( + id, + opts, + mux, + Some(TabResourceIdentity::terminal(None)?), + PtyLifetime::SessionOwned, + cell_pixels, + Some(host_identity), + ) + } + + #[cfg(test)] + fn spawn_for_test_with_lifetime_and_host_identity_at_cell_pixels( + id: SurfaceId, + opts: SurfaceOptions, + mux: Weak, + resource_identity: Option, + lifetime: PtyLifetime, + cell_pixels: (u16, u16), + host_identity: Option, ) -> anyhow::Result> { let terminal_public_id = resource_identity .as_ref() @@ -4079,11 +4976,13 @@ impl Surface { size: Mutex::new(initial_pty_size), control: test_master_control.clone(), })), - killer: Box::new(TestChildKiller), }), + local_process: Some(Box::new(LocalProcess::untracked(Box::new(TestChildKiller)))), + #[cfg(unix)] + hosted_shutdown_owner: None, lifetime, supports_clear_history_key_fallback: AtomicBool::new(false), - host_identity: None, + host_identity, #[cfg(unix)] pending_host_binding: Mutex::new(None), #[cfg(unix)] @@ -4130,6 +5029,41 @@ impl Surface { Ok(surface) } + #[cfg(test)] + pub(crate) fn set_server_shutdown_failure_for_test(&self, fail: bool) { + let Self::Pty(pty) = self else { return }; + let Some(process) = &pty.local_process else { return }; + let LocalProcess::Untracked(killer) = process.as_ref() else { return }; + *killer.lock().unwrap() = + if fail { Box::new(TestFailingChildKiller) } else { Box::new(TestChildKiller) }; + } + + #[cfg(test)] + pub(crate) fn set_server_shutdown_gate_for_test(&self, gate: Arc) { + let Self::Pty(pty) = self else { return }; + let Some(process) = &pty.local_process else { return }; + let LocalProcess::Untracked(killer) = process.as_ref() else { return }; + *killer.lock().unwrap() = Box::new(TestGatedFailingChildKiller(gate)); + } + + #[cfg(test)] + pub(crate) fn set_recovering_server_shutdown_for_test( + &self, + ) -> (Arc, Arc) { + let failing = Arc::new(AtomicBool::new(true)); + let attempts = Arc::new(TestAttemptCounter::default()); + let Self::Pty(pty) = self else { return (failing, attempts) }; + let Some(process) = &pty.local_process else { return (failing, attempts) }; + let LocalProcess::Untracked(killer) = process.as_ref() else { + return (failing, attempts); + }; + *killer.lock().unwrap() = Box::new(TestRecoveringChildKiller { + failing: failing.clone(), + attempts: attempts.clone(), + }); + (failing, attempts) + } + fn as_pty(&self) -> Option<&PtySurface> { match self { Surface::Pty(surface) => Some(surface), @@ -5329,6 +6263,18 @@ impl Surface { self.as_pty().and_then(|pty| pty.host_identity.clone()) } + #[cfg(unix)] + pub(crate) fn live_terminal_host_identity( + &self, + ) -> Option { + let pty = self.as_pty()?; + let runtime = pty.runtime.lock().unwrap(); + match &*runtime { + PtyRuntime::Hosted(host) => Some(host.identity()), + PtyRuntime::ExitedHosted | PtyRuntime::Local { .. } => None, + } + } + #[cfg(unix)] pub(crate) fn release_pending_terminal_host_binding(&self) { if let Some(pty) = self.as_pty() { @@ -5466,19 +6412,30 @@ impl Surface { } pub fn kill(&self) { + let _ = self.terminate_runtime(); + } + + pub(crate) fn release_kitty_image_budget(&self) { + if let Surface::Pty(pty) = self + && let Some(mux) = pty.mux.upgrade() + { + let _ = mux.unregister_kitty_image_surface(self); + } + } + + fn terminate_runtime(&self) -> bool { match self { Surface::Pty(pty) => { #[cfg(unix)] let mut terminate_fallback = None; - // Removal is authoritative. Prevent the mirror reader from - // racing termination by reconnecting a Surface that no longer - // exists in the mux topology. pty.owner_detaching.store(true, Ordering::Release); - { + let terminated = if let Some(process) = &pty.local_process { + process.request_hangup() + } else { let mut runtime = pty.runtime.lock().unwrap(); match &mut *runtime { - PtyRuntime::Local { killer, .. } => { - let _ = killer.kill(); + PtyRuntime::Local { .. } => { + unreachable!("local PTY runtime is missing process ownership") } #[cfg(unix)] PtyRuntime::Hosted(host) => { @@ -5488,27 +6445,97 @@ impl Surface { // shell into an undiscoverable orphan. if host.terminate().is_err() { terminate_fallback = Some(host.identity()); + false + } else { + true } } #[cfg(unix)] - PtyRuntime::ExitedHosted => {} + PtyRuntime::ExitedHosted => true, } - } + }; if let Some(mux) = pty.mux.upgrade() { #[cfg(unix)] if let Some(identity) = terminate_fallback { - mux.terminate_discovered_terminal_host( + let _ = mux.terminate_discovered_terminal_host( &identity.terminal_id, Some(&identity.incarnation), ); } let _ = mux.unregister_kitty_image_surface(self); } + terminated } - Surface::Browser(browser) => browser.kill(), + Surface::Browser(browser) => { + browser.kill(); + true + } + } + } + + /// Describe retained shutdown ownership without consuming or mutating it. + pub(crate) fn shutdown_owner_identity(&self) -> Option { + match self { + Surface::Pty(pty) => { + if pty.local_process.is_some() { + return Some(SurfaceShutdownOwnerIdentity::Surface); + } + #[cfg(unix)] + if let Some(owner) = &pty.hosted_shutdown_owner { + return Some(SurfaceShutdownOwnerIdentity::Hosted { + terminal_id: owner.record.terminal_id.clone(), + incarnation: owner.record.incarnation.clone(), + }); + } + None + } + Surface::Browser(browser) => { + browser.has_shutdown_owner().then_some(SurfaceShutdownOwnerIdentity::Surface) + } + } + } + + /// Start runtime termination. Durable terminal-host death is confirmed + /// separately through the process-bound discovery record. + pub(crate) fn shutdown_owner(&self) -> Option { + match self { + Surface::Pty(pty) => { + if let Some(process) = &pty.local_process { + return Some(SurfaceShutdownOwner { + kind: SurfaceShutdownOwnerKind::Local(process.as_ref().clone()), + }); + } + #[cfg(unix)] + if let Some(owner) = &pty.hosted_shutdown_owner { + return Some(SurfaceShutdownOwner { + kind: SurfaceShutdownOwnerKind::Hosted(owner.as_ref().clone()), + }); + } + None + } + Surface::Browser(browser) => browser.shutdown_owner().map(|owner| { + SurfaceShutdownOwner { kind: SurfaceShutdownOwnerKind::Browser(owner) } + }), } } + /// A local PTY process cannot survive its daemon, regardless of the + /// resource lifetime. Hosted runtimes have no local process owner here. + pub(crate) fn local_process_shutdown_owner(&self) -> Option { + let Surface::Pty(pty) = self else { return None }; + let process = match pty.local_process.as_deref()? { + LocalProcess::Owned(process) => LocalProcess::Owned(process.clone()), + #[cfg(test)] + LocalProcess::Untracked(killer) => LocalProcess::Untracked(killer.clone()), + }; + Some(SurfaceShutdownOwner { kind: SurfaceShutdownOwnerKind::Local(process) }) + } + + #[cfg(test)] + pub(crate) fn terminate_for_server_shutdown(&self, deadline: Instant) -> bool { + self.shutdown_owner().is_none_or(|owner| owner.terminate_until(deadline)) + } + pub(crate) fn disconnect_for_daemon_shutdown(&self) { match self { #[cfg(unix)] @@ -6046,17 +7073,146 @@ impl MasterPty for FdMasterPty { } #[cfg(test)] -#[derive(Debug)] -struct TestChildKiller; - -#[cfg(test)] -impl ChildKiller for TestChildKiller { +#[derive(Debug)] +struct TestChildKiller; + +#[cfg(test)] +impl ChildKiller for TestChildKiller { + fn kill(&mut self) -> std::io::Result<()> { + Ok(()) + } + + fn clone_killer(&self) -> Box { + Box::new(TestChildKiller) + } +} + +#[cfg(test)] +#[derive(Debug)] +struct TestFailingChildKiller; + +#[cfg(test)] +impl ChildKiller for TestFailingChildKiller { + fn kill(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("forced shutdown failure")) + } + + fn clone_killer(&self) -> Box { + Box::new(TestFailingChildKiller) + } +} + +#[cfg(test)] +#[derive(Debug, Default)] +pub(crate) struct TestLifecycleGate { + state: Mutex<(usize, bool)>, + changed: Condvar, +} + +#[cfg(test)] +impl TestLifecycleGate { + fn enter_and_wait(&self) { + let mut state = self.state.lock().unwrap(); + state.0 = state.0.saturating_add(1); + self.changed.notify_all(); + while !state.1 { + state = self.changed.wait(state).unwrap(); + } + } + + pub(crate) fn wait_until_entered(&self, expected: usize, deadline: Instant) -> bool { + let mut state = self.state.lock().unwrap(); + while state.0 < expected { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.changed.wait_timeout(state, remaining).unwrap(); + state = next; + if timeout.timed_out() && state.0 < expected { + return false; + } + } + true + } + + pub(crate) fn release(&self) { + let mut state = self.state.lock().unwrap(); + state.1 = true; + self.changed.notify_all(); + } +} + +#[cfg(test)] +#[derive(Debug)] +struct TestGatedFailingChildKiller(Arc); + +#[cfg(test)] +impl ChildKiller for TestGatedFailingChildKiller { + fn kill(&mut self) -> std::io::Result<()> { + self.0.enter_and_wait(); + Err(std::io::Error::other("forced gated shutdown failure")) + } + + fn clone_killer(&self) -> Box { + Box::new(Self(self.0.clone())) + } +} + +#[cfg(test)] +#[derive(Debug)] +struct TestRecoveringChildKiller { + failing: Arc, + attempts: Arc, +} + +#[cfg(test)] +#[derive(Debug, Default)] +pub(crate) struct TestAttemptCounter { + count: Mutex, + changed: Condvar, +} + +#[cfg(test)] +impl TestAttemptCounter { + fn increment(&self) { + let mut count = self.count.lock().unwrap(); + *count = count.saturating_add(1); + self.changed.notify_all(); + } + + pub(crate) fn load(&self) -> usize { + *self.count.lock().unwrap() + } + + pub(crate) fn wait_until(&self, expected: usize, deadline: Instant) -> bool { + let mut count = self.count.lock().unwrap(); + while *count < expected { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = self.changed.wait_timeout(count, remaining).unwrap(); + count = next; + if timeout.timed_out() && *count < expected { + return false; + } + } + true + } +} + +#[cfg(test)] +impl ChildKiller for TestRecoveringChildKiller { fn kill(&mut self) -> std::io::Result<()> { - Ok(()) + self.attempts.increment(); + if self.failing.load(Ordering::Acquire) { + Err(std::io::Error::other("forced transient shutdown failure")) + } else { + Ok(()) + } } fn clone_killer(&self) -> Box { - Box::new(TestChildKiller) + Box::new(Self { failing: self.failing.clone(), attempts: self.attempts.clone() }) } } @@ -6721,6 +7877,10 @@ fn terminal_color_override_delta( const RENDER_FRAME_CADENCE: Duration = Duration::from_millis(8); fn spawn_frame_producer(surface: &Arc, requests: Receiver) -> anyhow::Result<()> { + #[cfg(all(unix, test))] + if FAIL_NEXT_FRAME_PRODUCER_SPAWN.swap(false, Ordering::AcqRel) { + return Err(std::io::Error::other("forced frame producer spawn failure").into()); + } let weak = Arc::downgrade(surface); let id = surface.id; #[cfg(test)] @@ -6768,6 +7928,17 @@ fn spawn_frame_producer(surface: &Arc, requests: Receiver) -> anyh Ok(()) } +fn spawn_local_pty_reader( + id: SurfaceId, + run: impl FnOnce() + Send + 'static, +) -> std::io::Result> { + #[cfg(all(unix, test))] + if FAIL_NEXT_LOCAL_READER_SPAWN.swap(false, Ordering::AcqRel) { + return Err(std::io::Error::other("forced local PTY reader spawn failure")); + } + std::thread::Builder::new().name(format!("surface-{id}-reader")).spawn(run) +} + fn broadcast_render_scroll_locked(pty: &PtySurface, position: (u64, bool)) { let (offset, at_bottom) = position; let mut render = pty.render.lock().unwrap(); @@ -6903,6 +8074,38 @@ mod tests { assert_eq!(&*writer.0.lock().unwrap(), b"firstsecond"); } + #[cfg(unix)] + fn create_test_fifo(path: &std::path::Path) { + use std::os::unix::ffi::OsStrExt as _; + + let path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: path is a valid, null-terminated path owned for this call. + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + } + + #[cfg(unix)] + fn wait_for_test_fifo(path: &std::path::Path) { + use std::io::Read as _; + use std::os::fd::AsRawFd as _; + + let mut signal = std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap(); + let mut descriptor = + libc::pollfd { fd: signal.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: descriptor points to one initialized pollfd for this call. + assert_eq!(unsafe { libc::poll(&raw mut descriptor, 1, 2_000) }, 1); + assert_ne!(descriptor.revents & libc::POLLIN, 0); + let mut byte = [0_u8; 1]; + signal.read_exact(&mut byte).unwrap(); + } + + #[cfg(unix)] + fn signal_test_fifo(path: &std::path::Path) { + use std::io::Write as _; + + let mut signal = std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap(); + signal.write_all(b"ready").unwrap(); + } + fn append_disabled_kitty_replay_state(payload: &mut Vec) { for _ in 0..4 { payload.extend_from_slice(&0u64.to_le_bytes()); @@ -6928,6 +8131,247 @@ mod tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn local_pty_creation_waits_for_process_barrier() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_LOCAL_PTY_CREATION_BARRIER"; + const TEST_NAME: &str = "surface::tests::local_pty_creation_waits_for_process_barrier"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "PTY barrier subprocess failed: {status}"); + return; + } + + fn descriptor_count() -> usize { + std::fs::read_dir("/dev/fd").unwrap().count() + } + + drop(reserve_local_child_reaper().unwrap()); + let mux = Mux::new_for_test("local-pty-barrier", SurfaceOptions::default()); + let options = SurfaceOptions { + command: Some(vec!["/usr/bin/true".into()]), + ..SurfaceOptions::default() + }; + let baseline = descriptor_count(); + let process_barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (started_sender, started_receiver) = sync_channel(1); + let (result_sender, result_receiver) = sync_channel(1); + let worker = std::thread::spawn(move || { + started_sender.send(()).unwrap(); + result_sender.send(Surface::spawn(1, options, Arc::downgrade(&mux))).unwrap(); + }); + started_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!( + result_receiver.recv_timeout(Duration::from_millis(250)).is_err(), + "PTY spawn completed while the process barrier was held" + ); + let observed = descriptor_count(); + + drop(process_barrier); + let surface = result_receiver.recv_timeout(Duration::from_secs(1)).unwrap().unwrap(); + worker.join().unwrap(); + let _ = surface.terminate_for_server_shutdown(Instant::now() + Duration::from_secs(1)); + + assert_eq!(observed, baseline, "PTY descriptors were created outside the process barrier"); + } + + #[cfg(unix)] + #[test] + fn local_pty_does_not_inherit_launcher_command() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_LOCAL_LAUNCHER_ENV"; + const LAUNCHER_ENV: &str = "CMUX_TUI_LAUNCHER_COMMAND"; + const TEST_NAME: &str = "surface::tests::local_pty_does_not_inherit_launcher_command"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .env(LAUNCHER_ENV, "npx cmux@1.2.3") + .status() + .unwrap(); + assert!(status.success(), "local PTY environment subprocess failed: {status}"); + return; + } + + let root = std::env::temp_dir() + .join(format!("cmux-local-launcher-env-{}", crate::workspace_registry::new_uuid_v4())); + std::fs::create_dir_all(&root).unwrap(); + let result = root.join("launcher.txt"); + let ready = root.join("ready"); + let hold = root.join("hold"); + create_test_fifo(&ready); + create_test_fifo(&hold); + let mux = Mux::new_for_test("local-launcher-env", SurfaceOptions::default()); + let options = SurfaceOptions { + command: Some(vec![ + "/bin/sh".into(), + "-c".into(), + "result=\"$CMUX_TUI_TEST_RESULT\"; \ + printf '%s' \"${CMUX_TUI_LAUNCHER_COMMAND-unset}\" > \"$result.tmp\"; \ + mv \"$result.tmp\" \"$result\"; \ + printf ready > \"$CMUX_TUI_TEST_READY\"; \ + exec /bin/cat \"$CMUX_TUI_TEST_HOLD\"" + .into(), + ]), + extra_env: vec![ + ("CMUX_TUI_TEST_RESULT".into(), result.to_string_lossy().into_owned()), + ("CMUX_TUI_TEST_READY".into(), ready.to_string_lossy().into_owned()), + ("CMUX_TUI_TEST_HOLD".into(), hold.to_string_lossy().into_owned()), + ], + ..SurfaceOptions::default() + }; + let surface = Surface::spawn(1, options, Arc::downgrade(&mux)).unwrap(); + wait_for_test_fifo(&ready); + let inherited = std::fs::read_to_string(&result).unwrap(); + let _ = surface.terminate_for_server_shutdown(Instant::now() + Duration::from_secs(1)); + let _ = std::fs::remove_dir_all(root); + + assert_eq!(inherited, "unset", "local PTY inherited launcher replay metadata"); + } + + #[cfg(unix)] + #[test] + fn failed_local_pty_initialization_reaps_spawned_child() { + let root = std::env::temp_dir() + .join(format!("cmux-local-spawn-failure-{}", crate::workspace_registry::new_uuid_v4())); + std::fs::create_dir_all(&root).unwrap(); + let ready = root.join("leader.pid"); + let descendant = root.join("descendant"); + let signal = root.join("spawn-ready"); + let leader_hold = root.join("leader-hold"); + let descendant_hold = root.join("descendant-hold"); + create_test_fifo(&leader_hold); + create_test_fifo(&descendant_hold); + let _failure = crate::process_session::force_post_spawn_failure_for_test(signal.clone()); + let mux = Mux::new_for_test("local-spawn-failure", SurfaceOptions::default()); + let options = SurfaceOptions { + command: Some(vec![ + "/bin/sh".into(), + "-c".into(), + format!( + "trap '' HUP TERM; \ + (trap '' HUP TERM; exec /bin/cat {}) & \ + echo $! > {}; \ + echo $$ > {}; \ + printf ready > {}; exec /bin/cat {}", + descendant_hold.display(), + descendant.display(), + ready.display(), + signal.display(), + leader_hold.display() + ), + ]), + ..SurfaceOptions::default() + }; + + let error = Surface::spawn(1, options, Arc::downgrade(&mux)) + .expect_err("forced local PTY initialization failure unexpectedly succeeded"); + assert!(error.to_string().contains("forced post-spawn PTY initialization failure")); + let pid = std::fs::read_to_string(&ready).unwrap().trim().parse::().unwrap(); + let descendant_pid = + std::fs::read_to_string(&descendant).unwrap().trim().parse::().unwrap(); + let mut status = 0; + // SAFETY: the marker contains the exact child PID spawned by this test. + let result = unsafe { libc::waitpid(pid, &raw mut status, libc::WNOHANG) }; + let wait_error = std::io::Error::last_os_error(); + if result == 0 { + // SAFETY: the child is still owned by this test process. + unsafe { + libc::kill(pid, libc::SIGKILL); + libc::waitpid(pid, &raw mut status, 0); + } + } + // SAFETY: signal zero performs a non-mutating liveness probe. + let descendant_gone = unsafe { libc::kill(descendant_pid, 0) } < 0 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH); + if !descendant_gone { + // SAFETY: the marker names the test's retained-session descendant. + unsafe { + libc::kill(descendant_pid, libc::SIGKILL); + } + } + let _ = std::fs::remove_dir_all(root); + + assert_eq!(result, -1, "failed local PTY initialization left child {pid} unreaped"); + assert_eq!(wait_error.raw_os_error(), Some(libc::ECHILD)); + assert!( + descendant_gone, + "failed local PTY initialization left descendant {descendant_pid} running" + ); + } + + #[cfg(unix)] + fn assert_failed_local_worker_start_releases_reaper( + child_env: &str, + test_name: &str, + failure: &AtomicBool, + expected_error: &str, + ) { + if std::env::var_os(child_env).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name]) + .env(child_env, "1") + .status() + .unwrap(); + assert!(status.success(), "worker-start cleanup subprocess failed: {status}"); + return; + } + + drop(reserve_local_child_reaper().unwrap()); + let baseline = crate::process_session::reserved_child_reaper_active_for_test(); + failure.store(true, Ordering::Release); + let root = std::env::temp_dir().join(format!( + "cmux-local-worker-start-failure-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let hold = root.join("hold"); + create_test_fifo(&hold); + let mux = Mux::new_for_test("local-worker-start-failure", SurfaceOptions::default()); + let options = SurfaceOptions { + command: Some(vec!["/bin/cat".into(), hold.to_string_lossy().into_owned()]), + ..SurfaceOptions::default() + }; + + let error = Surface::spawn(1, options, Arc::downgrade(&mux)) + .expect_err("forced local worker-start failure unexpectedly succeeded"); + assert!(error.to_string().contains(expected_error), "unexpected spawn error: {error:#}"); + + let deadline = Instant::now() + Duration::from_secs(4); + assert!( + crate::process_session::wait_for_reserved_child_reaper_active_for_test( + baseline, deadline + ), + "failed local worker startup retained a child-reaper reservation" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn failed_frame_producer_start_releases_local_child() { + assert_failed_local_worker_start_releases_reaper( + "CMUX_TUI_TEST_FRAME_PRODUCER_START_FAILURE", + "surface::tests::failed_frame_producer_start_releases_local_child", + &FAIL_NEXT_FRAME_PRODUCER_SPAWN, + "forced frame producer spawn failure", + ); + } + + #[cfg(unix)] + #[test] + fn failed_pty_reader_start_releases_local_child() { + assert_failed_local_worker_start_releases_reaper( + "CMUX_TUI_TEST_PTY_READER_START_FAILURE", + "surface::tests::failed_pty_reader_start_releases_local_child", + &FAIL_NEXT_LOCAL_READER_SPAWN, + "forced local PTY reader spawn failure", + ); + } + #[cfg(target_os = "macos")] #[test] fn macos_surface_spawn_returns_within_deadline() { @@ -7013,6 +8457,381 @@ mod tests { assert_eq!(surface.resource_identity(), Some(&identity)); } + #[cfg(unix)] + #[derive(Debug)] + struct CountingChildKiller(Arc); + + #[cfg(unix)] + impl ChildKiller for CountingChildKiller { + fn kill(&mut self) -> std::io::Result<()> { + self.0.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + + fn clone_killer(&self) -> Box { + Box::new(Self(self.0.clone())) + } + } + + #[cfg(unix)] + #[test] + fn reaped_local_child_is_never_signaled_again() { + let signals = Arc::new(AtomicUsize::new(0)); + let process = LocalPtyProcess::new(None, Box::new(CountingChildKiller(signals.clone()))); + process.child_reaped.store(true, Ordering::Release); + + assert!(process.request_hangup()); + assert_eq!(signals.load(Ordering::Relaxed), 0); + drop(process); + assert_eq!(signals.load(Ordering::Relaxed), 0); + } + + #[cfg(unix)] + #[test] + fn local_session_kill_observes_the_waitable_leader_under_its_owner_lock() { + let root = std::env::temp_dir().join(format!( + "cmux-local-owner-observation-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let hold = root.join("hold"); + create_test_fifo(&hold); + let mux = Mux::new_for_test("local-owner-observation", SurfaceOptions::default()); + let options = SurfaceOptions { + command: Some(vec!["/bin/cat".into(), hold.to_string_lossy().into_owned()]), + ..SurfaceOptions::default() + }; + let surface = Surface::spawn(1, options, Arc::downgrade(&mux)).unwrap(); + let process = match surface.as_pty().unwrap().local_process.as_deref().unwrap() { + LocalProcess::Owned(process) => process.clone(), + LocalProcess::Untracked(_) => unreachable!("real PTY process must be tracked"), + }; + process.termination_started.store(true, Ordering::Release); + + let kill_process = process.clone(); + let (killed_tx, killed_rx) = sync_channel(1); + let killer = std::thread::spawn(move || { + let killed = kill_process + .kill_terminal_process_session_until(Instant::now() + Duration::from_secs(1), None); + killed_tx.send(killed).unwrap(); + }); + let killed = killed_rx + .recv_timeout(Duration::from_millis(500)) + .expect("session cleanup waited on the reaper while retaining its owner lock") + .unwrap(); + killer.join().unwrap(); + + process.group_escalation_complete.store(true, Ordering::Release); + process.exited.1.notify_all(); + process.wake_reserved_child_reaper(); + let reap_deadline = Instant::now() + Duration::from_secs(2); + + assert!(killed, "session cleanup could not observe its SIGKILLed leader"); + assert!(process.wait_for_child_reaped_until(reap_deadline)); + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn completed_natural_cleanup_does_not_revalidate_reusable_session_id() { + use std::os::unix::process::CommandExt as _; + + // A completed child may have its numeric session ID reassigned. Use a + // dedicated live session instead of assuming the test runner itself + // has a reusable session ID greater than one. + let mut command = std::process::Command::new("sleep"); + command.arg("60"); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut reused = command.spawn().unwrap(); + let reused_session = libc::pid_t::try_from(reused.id()).unwrap(); + // SAFETY: getsid only queries the live child spawned above. + assert_eq!(unsafe { libc::getsid(reused_session) }, reused_session); + let signals = Arc::new(AtomicUsize::new(0)); + let process = LocalPtyProcess::new( + u32::try_from(reused_session).ok(), + Box::new(CountingChildKiller(signals.clone())), + ); + process.group_escalation_complete.store(true, Ordering::Release); + process.child_reaped.store(true, Ordering::Release); + + assert!( + process.terminate_and_wait(Instant::now() + Duration::from_millis(100)), + "exact completed cleanup was rejected through a reused numeric session ID" + ); + assert_eq!(signals.load(Ordering::Relaxed), 0); + assert!( + reused.try_wait().unwrap().is_none(), + "completed cleanup signaled a reused session" + ); + let _ = reused.kill(); + let _ = reused.wait(); + } + + #[cfg(unix)] + #[test] + fn non_child_pid_is_not_reported_as_a_waitable_child() { + let current = + libc::pid_t::try_from(std::process::id()).expect("current process ID fits pid_t"); + + assert!( + !local_child_is_waitable(current).unwrap(), + "ECHILD was treated as proof that the numeric session remains reserved" + ); + } + + #[cfg(unix)] + #[test] + fn normal_child_exit_cleans_same_session_descendants_before_reaping() { + let root = std::env::temp_dir() + .join(format!("cmux-local-reap-{}", crate::workspace_registry::new_uuid_v4())); + std::fs::create_dir_all(&root).unwrap(); + let pid_path = root.join("descendant.pid"); + let ready_path = root.join("descendant.ready"); + let release_path = root.join("leader.release"); + let descendant_hold = root.join("descendant.hold"); + create_test_fifo(&ready_path); + create_test_fifo(&release_path); + create_test_fifo(&descendant_hold); + let mux = Mux::new_for_test("local-reap-descendants", SurfaceOptions::default()); + let options = SurfaceOptions { + command: Some(vec![ + "/bin/sh".into(), + "-c".into(), + format!( + "trap '' HUP TERM; terminal=$(tty); \ + /bin/cat {hold} 3<>\"$terminal\" & child=$!; \ + echo $child > {pid}; printf ready > {ready}; \ + /bin/cat {release}; exit 0", + hold = descendant_hold.display(), + ready = ready_path.display(), + pid = pid_path.display(), + release = release_path.display(), + ), + ]), + ..SurfaceOptions::default() + }; + let surface = Surface::spawn(1, options, Arc::downgrade(&mux)).unwrap(); + let process = match surface.as_pty().unwrap().local_process.as_deref().unwrap() { + LocalProcess::Owned(process) => process.clone(), + LocalProcess::Untracked(_) => unreachable!("real PTY process must be tracked"), + }; + wait_for_test_fifo(&ready_path); + let descendant = + std::fs::read_to_string(&pid_path).unwrap().trim().parse::().unwrap(); + // SAFETY: signal 0 only probes the test-owned PID. + assert_eq!(unsafe { libc::kill(descendant, 0) }, 0); + signal_test_fifo(&release_path); + let deadline = Instant::now() + Duration::from_secs(3); + let naturally_reaped = process.wait_for_child_reaped_until(deadline); + // SAFETY: signal 0 only probes the test-owned PID. + let descendant_alive_before_cleanup = unsafe { libc::kill(descendant, 0) } == 0; + + let terminated = + surface.terminate_for_server_shutdown(Instant::now() + Duration::from_secs(1)); + // SAFETY: signal 0 only probes the test-owned PID. + let descendant_alive = unsafe { libc::kill(descendant, 0) } == 0; + if descendant_alive { + // SAFETY: this PID was written by the test-owned descendant. + unsafe { + libc::kill(descendant, libc::SIGKILL); + } + } + let _ = std::fs::remove_dir_all(root); + + assert!( + naturally_reaped, + "normal PTY exit waited for EOF from a same-session descendant before cleanup" + ); + assert!(terminated); + assert!( + !descendant_alive_before_cleanup, + "normal PTY exit left a same-session descendant holding the slave open" + ); + } + + #[cfg(unix)] + #[test] + fn normal_child_exit_retries_a_failed_session_cleanup() { + let root = std::env::temp_dir() + .join(format!("cmux-local-reap-retry-{}", crate::workspace_registry::new_uuid_v4())); + std::fs::create_dir_all(&root).unwrap(); + let release_path = root.join("release"); + let mux = Mux::new_for_test("local-reap-retry", SurfaceOptions::default()); + let options = SurfaceOptions { + command: Some(vec![ + "/bin/sh".into(), + "-c".into(), + format!("while [ ! -e {} ]; do sleep 0.01; done", release_path.display()), + ]), + ..SurfaceOptions::default() + }; + let surface = Surface::spawn(1, options, Arc::downgrade(&mux)).unwrap(); + let process = match surface.as_pty().unwrap().local_process.as_deref().unwrap() { + LocalProcess::Owned(process) => process.clone(), + LocalProcess::Untracked(_) => unreachable!("real PTY process must be tracked"), + }; + process.normal_cleanup_failures.store(1, Ordering::Release); + std::fs::write(&release_path, b"ready").unwrap(); + + let retry_deadline = Instant::now() + Duration::from_secs(3); + let retried = process.wait_for_normal_cleanup_attempts_until(2, retry_deadline); + let reaped = process.wait_for_child_reaped_until(retry_deadline); + let _ = process.terminate_and_wait(Instant::now() + Duration::from_secs(1)); + let _ = std::fs::remove_dir_all(root); + + assert!(retried, "natural PTY cleanup was not retried after a transient failure"); + assert!(reaped, "the PTY leader remained unreaped after cleanup could succeed"); + } + + #[cfg(unix)] + #[test] + fn local_child_observation_uses_the_reserved_shared_reaper() { + const CHILDREN: usize = 8; + + let root = std::env::temp_dir() + .join(format!("cmux-local-reap-bound-{}", crate::workspace_registry::new_uuid_v4())); + std::fs::create_dir_all(&root).unwrap(); + let release_path = root.join("release"); + let mux = Mux::new_for_test("local-reap-bound", SurfaceOptions::default()); + let baseline = crate::process_session::dedicated_natural_reap_workers_for_test(); + let observer_baseline = dedicated_local_child_observer_spawns_for_test(); + let mut surfaces = Vec::new(); + let mut processes = Vec::new(); + for index in 0..CHILDREN { + let options = SurfaceOptions { + command: Some(vec![ + "/bin/sh".into(), + "-c".into(), + format!("while [ ! -e {} ]; do sleep 0.01; done", release_path.display()), + ]), + ..SurfaceOptions::default() + }; + let surface = Surface::spawn( + SurfaceId::try_from(index + 1).unwrap(), + options, + Arc::downgrade(&mux), + ) + .unwrap(); + let process = match surface.as_pty().unwrap().local_process.as_deref().unwrap() { + LocalProcess::Owned(process) => process.clone(), + LocalProcess::Untracked(_) => unreachable!("real PTY process must be tracked"), + }; + process.normal_cleanup_failures.store(usize::MAX, Ordering::Release); + surfaces.push(surface); + processes.push(process); + } + let dedicated_observers = + dedicated_local_child_observer_spawns_for_test().saturating_sub(observer_baseline); + std::fs::write(&release_path, b"ready").unwrap(); + + let deadline = Instant::now() + Duration::from_secs(3); + let all_attempted = processes + .iter() + .all(|process| process.wait_for_normal_cleanup_attempts_until(1, deadline)); + let dedicated = crate::process_session::dedicated_natural_reap_workers_for_test() + .saturating_sub(baseline); + + for process in &processes { + let _ = process.terminate_and_wait(Instant::now() + Duration::from_secs(2)); + } + drop(surfaces); + let _ = std::fs::remove_dir_all(root); + + assert!( + dedicated <= 1, + "persistent natural cleanup retained {dedicated} dedicated retry workers" + ); + assert_eq!( + dedicated_observers, 0, + "local child observation bypassed the reserved shared reaper" + ); + assert!(all_attempted, "a reserved child did not reach the shared cleanup owner"); + } + + #[cfg(unix)] + #[test] + fn explicit_shutdown_deadline_does_not_wait_for_natural_cleanup_sweep() { + let root = std::env::temp_dir() + .join(format!("cmux-local-reap-lock-{}", crate::workspace_registry::new_uuid_v4())); + std::fs::create_dir_all(&root).unwrap(); + let release_path = root.join("release"); + let mux = Mux::new_for_test("local-reap-lock", SurfaceOptions::default()); + let options = SurfaceOptions { + command: Some(vec![ + "/bin/sh".into(), + "-c".into(), + format!("while [ ! -e {} ]; do sleep 0.01; done", release_path.display()), + ]), + ..SurfaceOptions::default() + }; + let surface = Surface::spawn(1, options, Arc::downgrade(&mux)).unwrap(); + let process = match surface.as_pty().unwrap().local_process.as_deref().unwrap() { + LocalProcess::Owned(process) => process.clone(), + LocalProcess::Untracked(_) => unreachable!("real PTY process must be tracked"), + }; + let cleanup_gate = Arc::new(TestLifecycleGate::default()); + *process.normal_cleanup_gate.lock().unwrap() = Some(cleanup_gate.clone()); + std::fs::write(&release_path, b"ready").unwrap(); + let sweep_deadline = Instant::now() + crate::test_timeout(Duration::from_secs(1)); + assert!(cleanup_gate.wait_until_entered(1, sweep_deadline)); + + let shutdown_process = process.clone(); + let (shutdown_tx, shutdown_rx) = sync_channel(1); + let shutdown = std::thread::spawn(move || { + let result = shutdown_process.terminate_and_wait( + Instant::now() + crate::test_timeout(Duration::from_millis(50)), + ); + shutdown_tx.send(result).unwrap(); + }); + shutdown_rx + .recv_timeout(crate::test_timeout(Duration::from_millis(150))) + .expect("explicit shutdown waited behind the natural cleanup sweep"); + cleanup_gate.release(); + shutdown.join().unwrap(); + let _ = process + .terminate_and_wait(Instant::now() + crate::test_timeout(Duration::from_secs(1))); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn local_shutdown_does_not_wait_for_the_pty_writer_lock() { + let mux = Mux::new_for_test("shutdown-writer-lock", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let locked = surface.clone(); + let (held_tx, held_rx) = sync_channel(1); + let (release_tx, release_rx) = sync_channel(1); + let holder = std::thread::spawn(move || { + let pty = locked.as_pty().unwrap(); + let _runtime = pty.runtime.lock().unwrap(); + held_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }); + held_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let (shutdown_tx, shutdown_rx) = sync_channel(1); + let shutdown = std::thread::spawn(move || { + shutdown_tx + .send( + surface + .terminate_for_server_shutdown(Instant::now() + Duration::from_millis(25)), + ) + .unwrap(); + }); + assert!(shutdown_rx.recv_timeout(Duration::from_millis(100)).unwrap()); + + release_tx.send(()).unwrap(); + holder.join().unwrap(); + shutdown.join().unwrap(); + } + #[cfg(unix)] #[test] fn hosted_mirror_never_answers_terminal_queries() { @@ -7315,35 +9134,45 @@ mod tests { #[cfg(unix)] #[test] fn local_same_pair_alt_screen_roundtrip_forces_resolved_cursor_colors() { + let root = std::env::temp_dir().join(format!( + "cmux-local-cursor-activity-{}", + crate::workspace_registry::new_uuid_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let release_path = root.join("release"); let mux = Mux::new_for_test("local-cursor-activity", SurfaceOptions::default()); let options = SurfaceOptions { command: Some(vec![ "/bin/sh".into(), "-c".into(), - "sleep 0.2; printf '\\033[?1049h\\033[?1049l'; sleep 0.2".into(), + format!( + "while [ ! -e {} ]; do sleep 0.01; done; \ + printf '\\033[?1049h\\033[?1049l'; sleep 0.2", + release_path.display() + ), ]), ..SurfaceOptions::default() }; let surface = Surface::spawn(1, options, Arc::downgrade(&mux)).unwrap(); let attach = surface.attach_stream().unwrap(); let expected = (attach.colors.cursor_style, attach.colors.cursor_blink); - let deadline = Instant::now() + Duration::from_secs(2); + std::fs::write(&release_path, b"ready").unwrap(); + let deadline = Instant::now() + crate::test_timeout(Duration::from_secs(2)); let mut output = Vec::new(); let colors = loop { assert!(Instant::now() < deadline, "local cursor activity was not published"); - match attach.stream.recv_timeout(Duration::from_millis(250)) { + match attach.stream.recv_timeout(crate::test_timeout(Duration::from_millis(250))) { + Err(RecvTimeoutError::Timeout) => continue, + Err(error) => panic!("local cursor activity stream failed: {error}"), Ok(AttachFrame::Output(bytes)) => output.extend_from_slice(&bytes), Ok(AttachFrame::ColorsChanged(colors)) => break colors, Ok(AttachFrame::Resized { .. } | AttachFrame::ResizedWithColors { .. }) => {} Ok(AttachFrame::OutputWithColors { .. }) => { panic!("local PTYs must use ordered Output then ColorsChanged") } - Err(RecvTimeoutError::Timeout) => {} - Err(RecvTimeoutError::Disconnected) => { - panic!("local cursor activity stream disconnected") - } } }; + let _ = std::fs::remove_dir_all(root); assert!( output.windows(16).any(|window| window == b"\x1b[?1049h\x1b[?1049l"), @@ -8507,7 +10336,7 @@ mod tests { rendered.contains(FINAL_MARKER), "final render frame did not contain producer receipt: {rendered:?}" ); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs index 57cdf0e1e86d..900582d04399 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs @@ -48,6 +48,9 @@ pub const FLAG_SMART_RENDERER: u32 = 1 << 2; /// Protocol-v4 HostHello flag. The authenticated launch-owner connection must /// send `Activate` after its daemon has durably committed public topology. pub const FLAG_LAUNCH_ACTIVATION_REQUIRED: u32 = 1 << 3; +/// Protocol-v4 ClientHello opt-in and HostHello acknowledgement for an +/// authenticated owner that will send exactly one Terminate control. +pub const FLAG_TERMINATE_ONLY: u32 = 1 << 4; /// ResizeAck payload flag: this request changed the canonical grid and its /// sequenced Resized+Colors transition was enqueued immediately before the /// targeted acknowledgement. @@ -149,6 +152,7 @@ impl TerminalExit { /// /// cmux-pty's Unix backend returns `std::process::Child`, so failure to downcast /// is an alternate backend and becomes an explicit unknown outcome. +#[cfg(all(test, unix))] pub(crate) fn wait_for_native_child_status( child: &mut (dyn cmux_pty::Child + Send + Sync), ) -> TerminalExit { @@ -490,9 +494,10 @@ pub struct Frame { /// consumers stage the first frame and expose only the paired state. /// Snapshot keeps flags zero: its same-boundary Colors frame is a mandatory /// bootstrap rule rather than a live-stream transition. - /// ClientHello/HostHello may negotiate [`FLAG_VIEWER_SIZE_ACKS`]. Unknown - /// flags, flags on Colors or other message kinds, an unflagged Resized, and - /// a flagged live frame not followed by Colors are protocol errors. + /// ClientHello/HostHello may negotiate [`FLAG_VIEWER_SIZE_ACKS`] or + /// [`FLAG_TERMINATE_ONLY`]. Unknown flags, flags on Colors or other message + /// kinds, an unflagged Resized, and a flagged live frame not followed by + /// Colors are protocol errors. pub sequence: u64, pub payload: Vec, } diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs index 9f23100cd522..dc939795fb03 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs @@ -28,18 +28,22 @@ use crate::terminal_host_protocol::{ CLEAR_HISTORY_ACK_AMBIGUOUS, CLEAR_HISTORY_ACK_FALLBACK_UNREPRESENTABLE, CLEAR_HISTORY_ACK_FALLBACK_WRITE_TIMEOUT, CLEAR_HISTORY_ACK_KNOWN_NOT_DELIVERED, CLEAR_HISTORY_ACK_OK, CLEAR_HISTORY_ACK_PRESERVATION_FAILED, CLEAR_HISTORY_ACK_STREAM_TIMEOUT, - FLAG_COLORS_FOLLOW, FLAG_LAUNCH_ACTIVATION_REQUIRED, FLAG_SMART_RENDERER, + FLAG_COLORS_FOLLOW, FLAG_LAUNCH_ACTIVATION_REQUIRED, FLAG_SMART_RENDERER, FLAG_TERMINATE_ONLY, FLAG_VIEWER_SIZE_ACKS, Frame, HostLaunchFailure, HostLaunchFailureKind, KITTY_IMAGE_ALIAS_COUNT_LEN, KITTY_IMAGE_ALIAS_ENCODED_LEN, LAUNCH_ACTIVATION_PROTOCOL_VERSION, MAX_FRAME_PAYLOAD, MAX_KITTY_IMAGE_ALIASES, MessageKind, PROTOCOL_VERSION, RESIZE_ACK_CANONICAL_CHANGED, TerminalExit, decode_host_launch_failure, decode_terminal_exit, - encode_host_launch_failure, encode_terminal_exit, read_frame, wait_for_native_child_status, - write_frame, + encode_host_launch_failure, encode_terminal_exit, read_frame, write_frame, }; const HOST_RECORD_VERSION: u32 = 4; const LEGACY_PROTOCOL_VERSION: u16 = 1; const SMART_RENDERER_PROTOCOL_VERSION: u16 = 3; +// Version-2 branch records advertised terminate-only with bit 2. Version-3 +// records use the non-colliding bit from the merged protocol. The record +// version and advertised capability must both select this compatibility bit. +const LEGACY_TERMINATE_ONLY_FLAG: u32 = 1 << 2; +const LEGACY_V3_TERMINATE_ONLY_FLAG: u32 = 1 << 3; const HOST_EXIT_RECORD_VERSION: u32 = 1; const MAX_LAUNCH_PAYLOAD: usize = 1024 * 1024; const MAX_STRING: usize = 256 * 1024; @@ -49,8 +53,6 @@ const MAX_ENV: usize = 1024; const MAX_RENDERER_CAPABILITY_TTL: std::time::Duration = std::time::Duration::from_secs(60); pub(crate) const CONTROL_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); const HOST_HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); -const HOST_CONNECT_RETRY_WINDOW: std::time::Duration = std::time::Duration::from_secs(1); -const HOST_CONNECT_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10); const TERMINAL_HOST_PUBLICATION_LOCK_FILE: &str = ".publication.lock"; // Keep live PTY backpressure independent from the extra headroom needed by // one maximum Resized + Colors + targeted acknowledgement transition. @@ -145,6 +147,10 @@ pub struct TerminalHostRecord { /// hosts and must never receive the unknown SetDefaults message. #[serde(default)] pub supports_set_defaults: bool, + /// Additive handshake capability. Missing/false records require the + /// compatibility adoption path, which materializes a full Snapshot. + #[serde(default)] + pub supports_terminate_only: bool, /// Additive control capability. Missing/false records belong to legacy /// hosts and must never receive the unknown ClearHistory message. #[serde(default)] @@ -167,6 +173,7 @@ impl std::fmt::Debug for TerminalHostRecord { .field("host_start_nonce", &self.host_start_nonce) .field("workspace_key", &self.workspace_key) .field("supports_set_defaults", &self.supports_set_defaults) + .field("supports_terminate_only", &self.supports_terminate_only) .field("supports_clear_history", &self.supports_clear_history) .field("supports_terminate_ack", &self.supports_terminate_ack) .finish() @@ -412,17 +419,23 @@ mod unix { use std::fs::{self, File, OpenOptions}; use std::io as std_io; use std::io::{Read, Write}; + #[cfg(all(test, target_os = "linux"))] + use std::mem::{offset_of, size_of}; use std::os::fd::{AsRawFd, RawFd}; + #[cfg(all(test, target_os = "linux"))] + use std::os::fd::{FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}; - use std::os::unix::net::{UnixListener, UnixStream}; + #[cfg(test)] + use std::os::unix::net::UnixListener; + use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{ Receiver, RecvTimeoutError, Sender, SyncSender, channel as mpsc_channel, sync_channel, }; - use std::sync::{Arc, Condvar, Mutex, TryLockError, Weak}; + use std::sync::{Arc, Condvar, Mutex, OnceLock, TryLockError, Weak}; use std::thread; use std::time::{Duration, Instant}; @@ -437,10 +450,185 @@ mod unix { const HOST_KILL_WAIT: Duration = Duration::from_secs(2); const HOST_PTY_DRAIN_GRACE: Duration = Duration::from_millis(250); const HOST_FORCED_DRAIN_WINDOW: Duration = Duration::from_millis(100); + const HOST_CLIENT_EXIT_DRAIN: Duration = Duration::from_secs(1); const HOST_LAUNCH_ROLLBACK_WAIT: Duration = Duration::from_secs(4); + const HOST_LAUNCH_TIMEOUT: Duration = Duration::from_secs(10); + const HOST_LAUNCH_CANCEL_POLL: Duration = Duration::from_millis(25); const HOST_LAUNCH_OWNER_TIMEOUT: Duration = Duration::from_secs(5); + const HOST_PROCESS_REAPER_CAPACITY: usize = 4_096; + const HOST_PROCESS_REAPER_POLL: Duration = Duration::from_millis(25); + const HOST_PROCESS_REAPER_RETRY_MAX: Duration = Duration::from_secs(1); + const MAX_TERMINAL_HOST_RECORD_BYTES: usize = MAX_LAUNCH_PAYLOAD; + const MAX_TERMINAL_HOST_RECORDS: usize = 4_096; + const MAX_TERMINAL_HOST_CLIENTS: usize = 64; + const TERMINAL_HOST_RECORD_SCAN_TIMEOUT: Duration = Duration::from_secs(2); + static HOST_PROCESS_REAPER: OnceLock>> = OnceLock::new(); + #[cfg(test)] + static HOST_REAP_TEST_LOCK: Mutex<()> = Mutex::new(()); + #[cfg(test)] + static NEXT_HOST_NORMAL_CLEANUP_FAILURES: AtomicUsize = AtomicUsize::new(0); + + #[cfg(not(test))] + fn host_forced_drain_window() -> Duration { + HOST_FORCED_DRAIN_WINDOW + } + + #[cfg(test)] + fn host_forced_drain_window() -> Duration { + crate::test_timeout(HOST_FORCED_DRAIN_WINDOW) + } + #[cfg(test)] + static NEXT_HOST_PROCESS_REAPER_SPAWN_FAILURES: AtomicUsize = AtomicUsize::new(0); + #[cfg(test)] + static HOST_PROCESS_INLINE_WAITS: AtomicUsize = AtomicUsize::new(0); + #[cfg(test)] + static HOST_CHILD_OBSERVER_SPAWNS: AtomicUsize = AtomicUsize::new(0); + + struct HostProcessReaper { + sender: Sender, + active: Arc, + _worker: thread::JoinHandle<()>, + } + + struct HostProcessReaperLease { + sender: Sender, + active: Arc, + #[cfg(test)] + completion: Option>, + } + + impl Drop for HostProcessReaperLease { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + #[cfg(test)] + if let Some(completion) = self.completion.take() { + let _ = completion.send(()); + } + } + } + + struct HostProcessReapRequest { + child: std::process::Child, + _lease: HostProcessReaperLease, + next_attempt: Instant, + retry_delay: Duration, + } + + impl HostProcessReaper { + fn start() -> std::io::Result { + let (sender, receiver) = std::sync::mpsc::channel(); + let active = Arc::new(AtomicUsize::new(0)); + let worker = spawn_host_process_reaper_worker(receiver)?; + Ok(Self { sender, active, _worker: worker }) + } + + fn lease(&self) -> std::io::Result { + self.active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < HOST_PROCESS_REAPER_CAPACITY).then_some(active + 1) + }) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "terminal-host process reaper capacity exhausted", + ) + })?; + Ok(HostProcessReaperLease { + sender: self.sender.clone(), + active: self.active.clone(), + #[cfg(test)] + completion: None, + }) + } + } + + fn reserve_host_process_reaper() -> std::io::Result { + let mut slot = HOST_PROCESS_REAPER.get_or_init(|| Mutex::new(None)).lock().unwrap(); + if slot.is_none() { + *slot = Some(HostProcessReaper::start()?); + } + slot.as_ref().expect("terminal-host process reaper initialized").lease() + } + + fn spawn_host_process_reaper_worker( + receiver: Receiver, + ) -> std::io::Result> { + #[cfg(test)] + if NEXT_HOST_PROCESS_REAPER_SPAWN_FAILURES + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| remaining.checked_sub(1)) + .is_ok() + { + return Err(std::io::Error::other("forced terminal-host reaper spawn failure")); + } + thread::Builder::new() + .name("terminal-host-process-reaper".into()) + .spawn(move || run_host_process_reaper(receiver)) + } + + fn run_host_process_reaper(receiver: Receiver) { + let mut pending = Vec::::new(); + loop { + let received = if pending.is_empty() { + receiver.recv().map_err(|_| RecvTimeoutError::Disconnected) + } else { + let now = Instant::now(); + let wait = pending + .iter() + .map(|request| request.next_attempt.saturating_duration_since(now)) + .min() + .unwrap_or(HOST_PROCESS_REAPER_POLL); + receiver.recv_timeout(wait) + }; + match received { + Ok(request) => pending.push(request), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) if pending.is_empty() => return, + Err(RecvTimeoutError::Disconnected) => {} + } + pending.extend(receiver.try_iter()); + + let now = Instant::now(); + let mut index = 0; + while index < pending.len() { + if pending[index].next_attempt > now { + index += 1; + continue; + } + match pending[index].child.try_wait() { + Ok(Some(_)) => { + pending.swap_remove(index); + } + Ok(None) => { + let retry = pending[index].retry_delay; + pending[index].next_attempt = now + retry; + pending[index].retry_delay = + retry.saturating_mul(2).min(HOST_PROCESS_REAPER_RETRY_MAX); + index += 1; + } + Err(_) => { + let retry = pending[index].retry_delay; + pending[index].next_attempt = now + retry; + pending[index].retry_delay = + retry.saturating_mul(2).min(HOST_PROCESS_REAPER_RETRY_MAX); + index += 1; + } + } + } + } + } + + fn enqueue_host_process_reaper(lease: HostProcessReaperLease, child: std::process::Child) { + let sender = lease.sender.clone(); + sender + .send(HostProcessReapRequest { + child, + _lease: lease, + next_attempt: Instant::now(), + retry_delay: HOST_PROCESS_REAPER_POLL, + }) + .expect("terminal-host process reaper remains alive while registered"); + } const HOST_CLIENT_WRITE_TIMEOUT: Duration = Duration::from_secs(2); - const HOST_HANDSHAKE_TRANSIENT_RETRIES: usize = 1; const HOST_EXIT_PERSIST_RETRY_MIN: Duration = Duration::from_millis(100); const HOST_EXIT_PERSIST_RETRY_MAX: Duration = Duration::from_secs(5); const HOST_EXIT_PERSIST_REPORT_INTERVAL: Duration = Duration::from_secs(60); @@ -475,15 +663,27 @@ mod unix { struct SpawnedHostProcess { child: Option, + reaper: Option, } impl SpawnedHostProcess { - fn child_mut(&mut self) -> &mut std::process::Child { - self.child.as_mut().expect("terminal-host child is present") + fn with_reaper( + child: std::process::Child, + reaper: HostProcessReaperLease, + ) -> SpawnedHostProcess { + Self { child: Some(child), reaper: Some(reaper) } + } + + #[cfg(test)] + fn new_for_test(child: std::process::Child) -> (Self, Receiver<()>) { + let mut reaper = reserve_host_process_reaper().unwrap(); + let (completion, completed) = std::sync::mpsc::channel(); + reaper.completion = Some(completion); + (Self::with_reaper(child, reaper), completed) } - fn into_child(mut self) -> std::process::Child { - self.child.take().expect("terminal-host child is present") + fn child_mut(&mut self) -> &mut std::process::Child { + self.child.as_mut().expect("terminal-host child is present") } fn wait_timeout(&mut self, timeout: Duration) -> bool { @@ -493,6 +693,7 @@ mod unix { match child.try_wait() { Ok(Some(_)) => { self.child.take(); + self.reaper.take(); return true; } Ok(None) if Instant::now() < deadline => { @@ -502,17 +703,94 @@ mod unix { } } } + + fn detach_reaper(mut self) { + let Some(child) = self.child.take() else { return }; + let reaper = + self.reaper.take().expect("spawned terminal host retains its reaper reservation"); + enqueue_host_process_reaper(reaper, child); + } } impl Drop for SpawnedHostProcess { fn drop(&mut self) { - if let Some(child) = self.child.as_mut() { - let _ = child.kill(); - let _ = child.wait(); + let Some(mut child) = self.child.take() else { return }; + let _ = child.kill(); + let reaper = + self.reaper.take().expect("spawned terminal host retains its reaper reservation"); + enqueue_host_process_reaper(reaper, child); + } + } + + struct PublishedHostRecovery { + process: Option, + record_path: PathBuf, + terminal_id: String, + incarnation: String, + owner_token: String, + host_pid: u32, + } + + impl PublishedHostRecovery { + fn new( + process: SpawnedHostProcess, + record_path: PathBuf, + terminal_id: String, + incarnation: HostIncarnation, + owner_token: CapabilityToken, + host_pid: u32, + ) -> Self { + Self { + process: Some(process), + record_path, + terminal_id, + incarnation: incarnation.to_hex(), + owner_token: encode_hex(owner_token.as_bytes()), + host_pid, + } + } + + fn commit(mut self) -> SpawnedHostProcess { + self.process.take().expect("published terminal-host process is present") + } + + fn matching_record(&self) -> Option { + let record: TerminalHostRecord = + serde_json::from_slice(&fs::read(&self.record_path).ok()?).ok()?; + validate_terminal_host_record(&self.record_path, &record).ok()?; + (record.terminal_id == self.terminal_id + && record.incarnation == self.incarnation + && record.owner_token == self.owner_token + && record.host_pid == self.host_pid) + .then_some(record) + } + + fn reconcile(&mut self) { + let Some(process) = self.process.take() else { return }; + let Some(record) = self.matching_record() else { + // Publication was not proven, so the exact spawned process + // remains the only safe cleanup target. + drop(process); + return; + }; + + // The authenticated durable record now owns PTY cleanup. Never + // exact-kill the host after this handoff because its child runs + // in a separate session and may ignore terminal hangup. + process.detach_reaper(); + if let Ok(mut attachment) = connect_record(record, self.record_path.clone()) { + let _ = attachment.terminate(); + attachment.disconnect(); } } } + impl Drop for PublishedHostRecovery { + fn drop(&mut self) { + self.reconcile(); + } + } + #[derive(Debug)] struct HostLaunch { endpoint: String, @@ -1442,6 +1720,17 @@ mod unix { } } + pub(crate) fn terminate_until(&self, deadline: Instant) -> std::io::Result<()> { + let mut writer = self.writer.lock().unwrap(); + let frame = Frame::new(MessageKind::Terminate, Vec::new()); + let result = write_frame(&mut DeadlineStream::new(&mut writer, deadline), &frame) + .map_err(protocol_io_error); + if result.is_err() { + let _ = writer.shutdown(std::net::Shutdown::Both); + } + result + } + pub fn disconnect(&self) { let _ = self.writer.lock().unwrap().shutdown(std::net::Shutdown::Both); } @@ -1475,17 +1764,12 @@ mod unix { } /// Commit the launch ownership handoff after every fallible Surface - /// setup step succeeds. Until then, dropping this attachment exact- - /// kills and waits the child process through SpawnedHostProcess. + /// setup step succeeds. Until then, dropping this attachment asks the + /// host to exit, exact-kills it after the bounded rollback window, and + /// transfers the remaining wait to the shared process reaper. pub(crate) fn commit_launched_host(&mut self) { let Some(process) = self.launch_process.take() else { return }; - let mut child = process.into_child(); - // Reaping is housekeeping after the ownership handoff. Failure to - // create this helper cannot turn a committed live Surface into an - // error; dropping Child leaves the independent host running. - let _ = thread::Builder::new().name("terminal-host-reaper".into()).spawn(move || { - let _ = child.wait(); - }); + process.detach_reaper(); } /// Release a newly launched protocol-v4 host only after its public @@ -1663,11 +1947,29 @@ mod unix { state_root.join(format!("terminal-hosts-{}", stable_token(session))) } - /// Strip every descriptor except the private bootstrap stdio before the - /// hidden host starts any threads or opens its endpoint. This runs inside - /// the freshly exec'd `__terminal-host`, so descriptor enumeration is - /// race-free and cannot affect the daemon's own open files. - pub fn isolate_terminal_host_process_fds() -> anyhow::Result<()> { + fn terminal_host_publication_descriptor(args: &[String]) -> anyhow::Result> { + if args.first().map(String::as_str) != Some("--bootstrap-stdio") { + anyhow::bail!("hidden mode requires --bootstrap-stdio"); + } + match &args[1..] { + [] => Ok(None), + [flag, descriptor] if flag == "--publication-fd" => descriptor + .parse::() + .ok() + .filter(|descriptor| *descriptor > libc::STDERR_FILENO) + .map(Some) + .ok_or_else(|| anyhow::anyhow!("terminal-host publication descriptor is invalid")), + _ => anyhow::bail!("hidden mode received unsupported arguments"), + } + } + + /// Strip every descriptor except the private bootstrap stdio and the + /// explicitly inherited publication guard before the hidden host starts + /// any threads or opens its endpoint. This runs inside the freshly exec'd + /// `__terminal-host`, so descriptor enumeration is race-free and cannot + /// affect the daemon's own open files. + pub fn isolate_terminal_host_process_fds(args: &[String]) -> anyhow::Result<()> { + let publication_descriptor = terminal_host_publication_descriptor(args)?; let mut last_error = None; let mut inherited = None; for directory in ["/proc/self/fd", "/dev/fd"] { @@ -1693,6 +1995,9 @@ mod unix { ) })?; for descriptor in descriptors { + if Some(descriptor) == publication_descriptor { + continue; + } // SAFETY: descriptors came from this single-threaded process's // descriptor filesystem snapshot. stdio 0/1/2 is excluded. if unsafe { libc::close(descriptor) } != 0 { @@ -1716,15 +2021,35 @@ mod unix { default_colors: DefaultColors, cell_pixels: (u16, u16), kitty_graphics_limits: KittyGraphicsLimits, + ) -> anyhow::Result { + launch_terminal_host_cancellable( + options, + root, + default_colors, + cell_pixels, + kitty_graphics_limits, + &|| false, + ) + } + + pub(crate) fn launch_terminal_host_cancellable( + options: &SurfaceOptions, + root: &Path, + default_colors: DefaultColors, + cell_pixels: (u16, u16), + kitty_graphics_limits: KittyGraphicsLimits, + cancelled: &dyn Fn() -> bool, ) -> anyhow::Result { let terminal_id = TerminalId::random()?; - launch_terminal_host_with_identity( + let publication_guard = acquire_terminal_host_publication(root, &terminal_id.to_hex())?; + launch_terminal_host_with_identity_cancellable( options, root, default_colors, cell_pixels, kitty_graphics_limits, - terminal_id, + (terminal_id, publication_guard), + cancelled, ) } @@ -1739,6 +2064,29 @@ mod unix { kitty_graphics_limits: KittyGraphicsLimits, terminal_id: TerminalId, ) -> anyhow::Result { + let publication_guard = acquire_terminal_host_publication(root, &terminal_id.to_hex())?; + launch_terminal_host_with_identity_cancellable( + options, + root, + default_colors, + cell_pixels, + kitty_graphics_limits, + (terminal_id, publication_guard), + &|| false, + ) + } + + pub(crate) fn launch_terminal_host_with_identity_cancellable( + options: &SurfaceOptions, + root: &Path, + default_colors: DefaultColors, + cell_pixels: (u16, u16), + kitty_graphics_limits: KittyGraphicsLimits, + ownership: (TerminalId, crate::PublicationGuard), + cancelled: &dyn Fn() -> bool, + ) -> anyhow::Result { + let (terminal_id, publication_guard) = ownership; + prepare_private_dir(root)?; let launch_publication_lock = reserve_terminal_host_publication(root)?; let owner_token = CapabilityToken::random()?; let terminal_hex = encode_hex(terminal_id.as_bytes()); @@ -1751,6 +2099,10 @@ mod unix { prepare_private_dir(&endpoint_root)?; let endpoint = endpoint_root.join(format!("{terminal_hex}.sock")); let record_path = root.join(format!("{terminal_hex}.json")); + anyhow::ensure!( + publication_guard.target() == record_path, + "terminal-host publication ownership has the wrong target" + ); if record_path.exists() || endpoint.exists() { anyhow::bail!("terminal host identity already exists"); } @@ -1796,13 +2148,23 @@ mod unix { if libc::setsid() < 0 { Err(std::io::Error::last_os_error()) } else { Ok(()) } }); } - let child = command.spawn().context("spawn terminal-host process")?; - let mut process = SpawnedHostProcess { child: Some(child) }; + let publication_descriptor = publication_guard.inherit_into(&mut command); + command.args(["--publication-fd", &publication_descriptor.to_string()]); + let reaper = reserve_host_process_reaper() + .context("reserve bounded terminal-host process cleanup")?; + let child = cmux_tui_process::spawn(&mut command).context("spawn terminal-host process")?; + let mut process = SpawnedHostProcess::with_reaper(child, reaper); let host_pid = process.child_mut().id(); - let mut stdin = + let stdin = process.child_mut().stdin.take().context("open terminal-host bootstrap stdin")?; - let mut stdout = + let stdout = process.child_mut().stdout.take().context("open terminal-host bootstrap stdout")?; + set_nonblocking(stdin.as_raw_fd())?; + let launch_deadline = Instant::now() + HOST_LAUNCH_TIMEOUT; + let mut stdin = + CancellableHostWriter { writer: stdin, deadline: launch_deadline, cancelled }; + let mut stdout = + CancellableHostReader { reader: stdout, deadline: launch_deadline, cancelled }; let bootstrap = HostBootstrap { min_version: PROTOCOL_VERSION, @@ -1811,7 +2173,20 @@ mod unix { owner_token, }; write_frame(&mut stdin, &bootstrap.into_frame(1))?; - let ready_frame = read_required_frame(&mut stdout, "bootstrap ready")?; + let ready_frame = match read_required_frame(&mut stdout, "bootstrap ready") { + Ok(frame) => frame, + Err(error) => { + if cancelled() && record_path.exists() { + // Publication transfers PTY ownership to the durable + // record. Keep that host discoverable so the shutdown + // coordinator can terminate it through the authenticated + // endpoint instead of killing the host and orphaning its + // shell. + process.detach_reaper(); + } + return Err(error); + } + }; if ready_frame.kind != MessageKind::Ready { anyhow::bail!("terminal host returned {:?} instead of Ready", ready_frame.kind); } @@ -1820,9 +2195,32 @@ mod unix { anyhow::bail!("terminal host changed terminal identity during bootstrap"); } + let launch_recovery = PublishedHostRecovery::new( + process, + record_path.clone(), + terminal_hex.clone(), + ready.incarnation, + owner_token, + host_pid, + ); let mut launch_frame = Frame::new(MessageKind::Launch, launch.encode()?); launch_frame.request_id = 2; write_frame(&mut stdin, &launch_frame)?; + // Integration synchronization seam for cancellation after the host + // has published its durable record but before the launcher consumes + // Ready. The host remains free to serve authenticated termination. + if let Some(barrier) = std::env::var_os("CMUX_TUI_TEST_LAUNCH_ACK_BARRIER") { + let barrier = PathBuf::from(barrier); + while !record_path.exists() && !cancelled() && Instant::now() < launch_deadline { + thread::sleep(Duration::from_millis(5)); + } + if record_path.exists() { + fs::write(&barrier, b"published")?; + while barrier.exists() && !cancelled() && Instant::now() < launch_deadline { + thread::sleep(Duration::from_millis(5)); + } + } + } let launched_frame = read_required_frame(&mut stdout, "launch ready")?; if launched_frame.request_id != 2 { anyhow::bail!("terminal host did not acknowledge launch"); @@ -1858,7 +2256,11 @@ mod unix { // would leave a live published host while the mux marks its registry // row Exited. let mut attachment = connect_record(record, record_path)?; - attachment.launch_process = Some(process); + attachment.launch_process = Some(launch_recovery.commit()); + if cancelled() { + drop(attachment); + anyhow::bail!("terminal host launch cancelled because the server is shutting down"); + } debug_assert_eq!( attachment.launch_activation_pending, attachment.protocol_version >= LAUNCH_ACTIVATION_PROTOCOL_VERSION @@ -1866,6 +2268,140 @@ mod unix { Ok(attachment) } + fn set_nonblocking(descriptor: RawFd) -> std::io::Result<()> { + // SAFETY: F_GETFL reads flags from the valid owned pipe descriptor. + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFL) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + if flags & libc::O_NONBLOCK != 0 { + return Ok(()); + } + // SAFETY: F_SETFL updates status flags on the same valid descriptor. + if unsafe { libc::fcntl(descriptor, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + + struct CancellableHostWriter<'a, W> { + writer: W, + deadline: Instant, + cancelled: &'a dyn Fn() -> bool, + } + + impl Write for CancellableHostWriter<'_, W> { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + if buffer.is_empty() { + return Ok(0); + } + loop { + if (self.cancelled)() { + return Err(std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + "terminal host launch cancelled during server shutdown", + )); + } + let Some(remaining) = self.deadline.checked_duration_since(Instant::now()) else { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "terminal host launch timed out", + )); + }; + let wait = remaining.min(HOST_LAUNCH_CANCEL_POLL); + let timeout_ms = wait.as_millis().max(1).min(i32::MAX as u128) as i32; + let mut descriptor = libc::pollfd { + fd: self.writer.as_raw_fd(), + events: libc::POLLOUT | libc::POLLHUP, + revents: 0, + }; + let result = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; + if result > 0 { + if descriptor.revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "terminal host bootstrap pipe became invalid", + )); + } + match self.writer.write(buffer) { + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted + ) => + { + continue; + } + result => return result, + } + } + if result == 0 { + continue; + } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error); + } + } + } + + fn flush(&mut self) -> std::io::Result<()> { + self.writer.flush() + } + } + + struct CancellableHostReader<'a, R> { + reader: R, + deadline: Instant, + cancelled: &'a dyn Fn() -> bool, + } + + impl Read for CancellableHostReader<'_, R> { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + loop { + if (self.cancelled)() { + return Err(std::io::Error::new( + // `Read::read_exact` transparently retries + // `Interrupted`, so cancellation needs a terminal + // error kind to release the surface-creation permit. + std::io::ErrorKind::ConnectionAborted, + "terminal host launch cancelled during server shutdown", + )); + } + let Some(remaining) = self.deadline.checked_duration_since(Instant::now()) else { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "terminal host launch timed out", + )); + }; + let wait = remaining.min(HOST_LAUNCH_CANCEL_POLL); + let timeout_ms = wait.as_millis().max(1).min(i32::MAX as u128) as i32; + let mut descriptor = libc::pollfd { + fd: self.reader.as_raw_fd(), + events: libc::POLLIN | libc::POLLHUP, + revents: 0, + }; + let result = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; + if result > 0 { + if descriptor.revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "terminal host bootstrap pipe became invalid", + )); + } + return self.reader.read(buffer); + } + if result == 0 { + continue; + } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error); + } + } + } + } + pub fn adopt_terminal_host( record: TerminalHostRecord, record_path: PathBuf, @@ -1876,6 +2412,83 @@ mod unix { Ok(attachment) } + pub(crate) fn adopt_terminal_host_until( + record: TerminalHostRecord, + record_path: PathBuf, + deadline: Instant, + ) -> anyhow::Result { + validate_terminal_host_record(&record_path, &record)?; + connect_record_until(record, record_path, deadline) + } + + #[cfg(test)] + pub(crate) fn terminate_terminal_host_with_timeout( + record: &TerminalHostRecord, + record_path: &Path, + timeout: Duration, + ) -> anyhow::Result<()> { + terminate_terminal_host_until(record, record_path, Instant::now() + timeout) + } + + fn terminate_terminal_host_until( + record: &TerminalHostRecord, + record_path: &Path, + deadline: Instant, + ) -> anyhow::Result<()> { + validate_terminal_host_record(record_path, record)?; + let (terminate_only_flag, protocol_version) = match record.record_version { + 2 if record.supports_terminate_only => { + (LEGACY_TERMINATE_ONLY_FLAG, SMART_RENDERER_PROTOCOL_VERSION) + } + 3 if record.supports_terminate_only => { + (LEGACY_V3_TERMINATE_ONLY_FLAG, SMART_RENDERER_PROTOCOL_VERSION) + } + HOST_RECORD_VERSION if record.supports_terminate_only => { + (FLAG_TERMINATE_ONLY, PROTOCOL_VERSION) + } + _ => anyhow::bail!( + "terminal host record does not advertise a supported terminate-only handshake" + ), + }; + let terminal_id = TerminalId::from_bytes(decode_hex_array(&record.terminal_id)?); + let incarnation = HostIncarnation::from_bytes(decode_hex_array(&record.incarnation)?); + let owner_token = CapabilityToken::from_bytes(decode_hex_array(&record.owner_token)?); + let mut stream = connect_until(Path::new(&record.endpoint), deadline)?; + let mut stream = DeadlineStream::new(&mut stream, deadline); + let hello = ClientHello { + min_version: protocol_version, + max_version: protocol_version, + role: ClientRole::Admin, + requested_rights: CapabilityRights::TERMINATE, + terminal_id, + token: owner_token, + }; + let mut hello = hello.into_frame(1); + hello.version = protocol_version; + hello.flags = terminate_only_flag; + write_frame(&mut stream, &hello)?; + let response = read_required_frame(&mut stream, "terminate-only host hello")?; + if response.kind != MessageKind::HostHello + || response.version != protocol_version + || response.flags != terminate_only_flag + || response.request_id != 1 + || response.sequence != 0 + { + anyhow::bail!("terminal host rejected terminate-only handshake"); + } + let response = HostHello::decode(&response.payload)?; + if response.selected_version != protocol_version + || response.granted_rights != CapabilityRights::TERMINATE + || response.terminal_id != terminal_id + || response.incarnation != incarnation + { + anyhow::bail!("terminate-only host identity or rights changed"); + } + write_frame(&mut stream, &Frame::new(MessageKind::Terminate, Vec::new()))?; + stream.shutdown(std::net::Shutdown::Write)?; + Ok(()) + } + pub(crate) fn adopt_current_terminal_host( record: TerminalHostRecord, record_path: PathBuf, @@ -1946,6 +2559,7 @@ mod unix { if record.host_pid != 0 || !record.host_start_nonce.is_empty() || record.supports_set_defaults + || record.supports_terminate_only || record.supports_clear_history || record.supports_terminate_ack { @@ -2113,73 +2727,280 @@ mod unix { pub fn load_terminal_host_records( root: &Path, ) -> anyhow::Result> { - load_terminal_host_records_with_policy(root, false) + load_terminal_host_records_until( + root, + MAX_TERMINAL_HOST_RECORDS, + Instant::now() + TERMINAL_HOST_RECORD_SCAN_TIMEOUT, + TerminalHostRecordLoadMode::Tolerant, + ) } - pub(crate) fn load_terminal_host_records_for_reset( - root: &Path, - ) -> anyhow::Result> { - load_terminal_host_records_with_policy(root, true) + fn terminal_host_record_path(root: &Path, terminal_id: &str) -> anyhow::Result { + let terminal = TerminalId::from_hex(terminal_id) + .ok_or_else(|| anyhow::anyhow!("terminal-host id is not a canonical UUIDv4"))?; + anyhow::ensure!(terminal.to_hex() == terminal_id, "terminal-host id is not canonical"); + Ok(root.join(format!("{terminal_id}.json"))) } - fn load_terminal_host_records_with_policy( + /// Exclude a still-launching host before treating its absent canonical + /// record as proof that no PTY owner can publish later. + pub(crate) fn acquire_terminal_host_publication( root: &Path, - fail_closed: bool, - ) -> anyhow::Result> { + terminal_id: &str, + ) -> anyhow::Result { + prepare_private_dir(root)?; + let path = terminal_host_record_path(root, terminal_id)?; + crate::PublicationGuard::acquire(&path, Instant::now() + TERMINAL_HOST_RECORD_SCAN_TIMEOUT) + .with_context(|| format!("wait for terminal-host publication {}", path.display())) + } + + /// Load the one canonical discovery record for `terminal_id` without + /// scanning unrelated hosts. A missing record means there is no discovery + /// owner to terminate; malformed or unreadable records remain errors so a + /// close cannot silently abandon a live host. + pub(crate) fn load_terminal_host_record( + root: &Path, + terminal_id: &str, + ) -> anyhow::Result> { + let path = terminal_host_record_path(root, terminal_id)?; + match read_terminal_host_record_until( + &path, + Instant::now() + TERMINAL_HOST_RECORD_SCAN_TIMEOUT, + ) { + Ok(record) => Ok(Some((path, record))), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + Ok(None) + } + Err(error) => Err(error), + } + } + + /// Load every discovery record without omission. Adoption tolerates + /// transient or stale files, but an atomic server shutdown must account + /// for every record before it discards topology. + pub fn load_terminal_host_records_strict( + root: &Path, + max_records: usize, + deadline: Instant, + ) -> anyhow::Result> { + load_terminal_host_records_until( + root, + max_records, + deadline, + TerminalHostRecordLoadMode::Strict, + ) + } + + pub(crate) fn load_terminal_host_records_for_reset( + root: &Path, + ) -> anyhow::Result> { + load_terminal_host_records_until( + root, + MAX_TERMINAL_HOST_RECORDS, + Instant::now() + TERMINAL_HOST_RECORD_SCAN_TIMEOUT, + TerminalHostRecordLoadMode::Reset, + ) + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum TerminalHostRecordLoadMode { + Tolerant, + Strict, + Reset, + } + + fn load_terminal_host_records_until( + root: &Path, + max_records: usize, + deadline: Instant, + mode: TerminalHostRecordLoadMode, + ) -> anyhow::Result> { + ensure_terminal_host_scan_before(deadline)?; let mut records = Vec::new(); let mut identities = HashSet::new(); + let mut candidates = 0usize; let entries = match fs::read_dir(root) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(records), Err(error) => return Err(error.into()), }; for entry in entries { + ensure_terminal_host_scan_before(deadline)?; let entry = entry?; let path = entry.path(); if path.extension().and_then(|value| value.to_str()) != Some("json") { continue; } - let bytes = match fs::read(&path) { - Ok(bytes) => bytes, - Err(_) if !fail_closed => continue, - Err(error) => { - return Err(error) - .with_context(|| format!("read terminal-host record {}", path.display())); - } - }; - let record = match serde_json::from_slice::(&bytes) { + candidates = candidates.saturating_add(1); + if candidates > max_records { + anyhow::bail!("terminal-host record count exceeds capacity {max_records}"); + } + let record = match read_terminal_host_record_until(&path, deadline) { Ok(record) => record, - Err(_) if !fail_closed => continue, - Err(error) => { - return Err(error).with_context(|| { - format!("decode terminal-host record {}", path.display()) - }); + Err(_) if mode == TerminalHostRecordLoadMode::Tolerant => { + ensure_terminal_host_scan_before(deadline)?; + continue; } + Err(error) => return Err(error), }; - if let Err(error) = validate_terminal_host_record(&path, &record) { - if fail_closed { - return Err(error).with_context(|| { - format!("validate terminal-host record {}", path.display()) - }); - } - continue; - } if !identities.insert((record.terminal_id.clone(), record.incarnation.clone())) { - if fail_closed { - anyhow::bail!("duplicate terminal-host identity in {}", path.display()); + if mode != TerminalHostRecordLoadMode::Tolerant { + anyhow::bail!( + "duplicate terminal-host identity {}:{}", + record.terminal_id, + record.incarnation + ); } continue; } records.push((path, record)); } - // Reset uses records only for marker membership and liveness checks, so - // keep its fail-closed scan linear. - if !fail_closed { + ensure_terminal_host_scan_before(deadline)?; + if mode != TerminalHostRecordLoadMode::Reset { records.sort_by(|left, right| left.0.cmp(&right.0)); } Ok(records) } + fn ensure_terminal_host_scan_before(deadline: Instant) -> anyhow::Result<()> { + if Instant::now() >= deadline { + anyhow::bail!("terminal-host record scan exceeded the shutdown deadline"); + } + Ok(()) + } + + fn read_terminal_host_record_until( + path: &Path, + deadline: Instant, + ) -> anyhow::Result { + ensure_terminal_host_scan_before(deadline)?; + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .with_context(|| format!("open terminal-host record {}", path.display()))?; + let metadata = file + .metadata() + .with_context(|| format!("inspect terminal-host record {}", path.display()))?; + let expected_uid = fs::metadata( + path.parent() + .ok_or_else(|| anyhow::anyhow!("terminal-host record has no parent directory"))?, + )? + .uid(); + if !metadata.file_type().is_file() + || metadata.uid() != expected_uid + || metadata.nlink() != 1 + || metadata.mode() & 0o077 != 0 + { + anyhow::bail!("terminal-host record permissions or ownership are unsafe"); + } + if metadata.len() > MAX_TERMINAL_HOST_RECORD_BYTES as u64 { + anyhow::bail!("terminal-host record {} exceeds size limit", path.display()); + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + let mut buffer = [0u8; 8 * 1024]; + loop { + ensure_terminal_host_scan_before(deadline)?; + let remaining = MAX_TERMINAL_HOST_RECORD_BYTES + 1 - bytes.len(); + let read_len = buffer.len().min(remaining); + let read = file + .read(&mut buffer[..read_len]) + .with_context(|| format!("read terminal-host record {}", path.display()))?; + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + if bytes.len() > MAX_TERMINAL_HOST_RECORD_BYTES { + anyhow::bail!("terminal-host record {} exceeds size limit", path.display()); + } + } + ensure_terminal_host_scan_before(deadline)?; + let current = fs::symlink_metadata(path) + .with_context(|| format!("reinspect terminal-host record {}", path.display()))?; + if !current.file_type().is_file() + || current.dev() != metadata.dev() + || current.ino() != metadata.ino() + { + anyhow::bail!("terminal-host record changed while being read"); + } + let record = serde_json::from_slice::(&bytes) + .with_context(|| format!("decode terminal-host record {}", path.display()))?; + validate_terminal_host_record(path, &record) + .with_context(|| format!("validate terminal-host record {}", path.display()))?; + Ok(record) + } + + /// Terminate one exact host incarnation and retain its discovery record + /// until process-bound liveness proves that host is dead. + pub(crate) fn terminate_and_confirm_terminal_host_record( + record: &TerminalHostRecord, + record_path: &Path, + deadline: Instant, + ) -> bool { + let mut last_terminate_attempt = None; + loop { + match terminal_host_record_liveness(record_path, record) + .unwrap_or(TerminalHostLiveness::Indeterminate) + { + TerminalHostLiveness::Dead => { + let exit = match terminal_host_exit_record(record_path) { + Ok(exit) => exit, + Err(_) => return false, + }; + if let Some((exit_path, exit)) = exit { + if exit.terminal_id != record.terminal_id + || exit.incarnation != record.incarnation + { + return false; + } + match acknowledge_terminal_host_exit_record(&exit_path, &exit) { + Ok(true) => {} + Ok(false) if !exit_path.exists() => {} + Ok(false) | Err(_) => return false, + } + } + return match remove_stale_terminal_host_record(record_path, record) { + Ok(removed) => removed, + Err(_) if !record_path.exists() => true, + Err(_) => false, + }; + } + TerminalHostLiveness::Live | TerminalHostLiveness::Indeterminate => {} + } + + let now = Instant::now(); + if last_terminate_attempt.is_none_or(|attempt: Instant| { + now.duration_since(attempt) >= Duration::from_millis(100) + }) { + let attempt_deadline = deadline.min(Instant::now() + Duration::from_millis(100)); + if Instant::now() < attempt_deadline { + if record.supports_terminate_only { + let _ = + terminate_terminal_host_until(record, record_path, attempt_deadline); + } else if let Ok(host) = adopt_terminal_host_until( + record.clone(), + record_path.to_path_buf(), + attempt_deadline, + ) { + let _ = host.terminate_until(attempt_deadline); + host.disconnect(); + } + last_terminate_attempt = Some(Instant::now()); + } + } + let now = Instant::now(); + if now >= deadline { + return false; + } + thread::sleep(deadline.saturating_duration_since(now).min(Duration::from_millis(10))); + } + } + pub fn validate_terminal_host_exit_record( record_path: &Path, record: &TerminalHostExitRecord, @@ -2298,55 +3119,29 @@ mod unix { record_path: PathBuf, handshake_timeout: Duration, ) -> anyhow::Result { - let endpoint = PathBuf::from(&record.endpoint); - let mut stream = Some( - connect_with_retry(&endpoint) - .with_context(|| format!("connect terminal host at {}", endpoint.display()))?, - ); + connect_record_until(record, record_path, Instant::now() + handshake_timeout) + } + + fn connect_record_until( + record: TerminalHostRecord, + record_path: PathBuf, + deadline: Instant, + ) -> anyhow::Result { let mut failures = Vec::new(); let attempts = std::iter::once((PROTOCOL_VERSION, true)).chain( (LEGACY_PROTOCOL_VERSION..=PROTOCOL_VERSION).rev().map(|version| (version, false)), ); - 'protocols: for (protocol_version, smart_renderer) in attempts { - let mut transient_retries = 0; - loop { - let error = match connect_record_at_version( - record.clone(), - record_path.clone(), - handshake_timeout, - protocol_version, - smart_renderer, - stream.take().expect("protocol attempt has a connected stream"), - ) { - Ok(attachment) => return Ok(attachment), - Err(error) => error, - }; - if transient_retries < HOST_HANDSHAKE_TRANSIENT_RETRIES - && is_transient_handshake_transport(&error) - { - transient_retries += 1; - failures.push(format!( - "protocol {protocol_version} transient attempt {transient_retries}: {error:#}" - )); - match connect_with_retry(&endpoint) { - Ok(next_stream) => { - stream = Some(next_stream); - continue; - } - Err(reconnect_error) => { - failures.push(format!("protocol retry reconnect: {reconnect_error:#}")); - break 'protocols; - } - } - } - failures.push(format!("protocol {protocol_version}: {error:#}")); - break; - } - match connect_with_retry(&endpoint) { - Ok(next_stream) => stream = Some(next_stream), + for (protocol_version, smart_renderer) in attempts { + match connect_record_at_version_until( + record.clone(), + record_path.clone(), + deadline, + protocol_version, + smart_renderer, + ) { + Ok(attachment) => return Ok(attachment), Err(error) => { - failures.push(format!("protocol fallback reconnect: {error:#}")); - break; + failures.push(format!("protocol {protocol_version}: {error:#}")); } } } @@ -2359,47 +3154,23 @@ mod unix { handshake_timeout: Duration, ) -> anyhow::Result { if record.record_version >= HOST_RECORD_VERSION { - // Fence-capable records are emitted only by the current smart - // protocol. After an existing owner connection fails, probing - // every legacy version can outlive the control request while the - // already-terminating host removes its socket. One current - // handshake is sufficient; durable tombstone reconciliation - // retries independently if that bounded attempt loses the race. - let endpoint = PathBuf::from(&record.endpoint); - let stream = connect_with_retry(&endpoint) - .with_context(|| format!("connect terminal host at {}", endpoint.display()))?; - return connect_record_at_version( + return connect_record_at_version_until( record, record_path, - handshake_timeout, + Instant::now() + handshake_timeout, PROTOCOL_VERSION, true, - stream, ); } connect_record_with_timeout(record, record_path, handshake_timeout) } - fn is_transient_handshake_transport(error: &anyhow::Error) -> bool { - error.chain().any(|cause| { - cause.downcast_ref::().is_some_and(|error| { - matches!( - error.kind(), - std_io::ErrorKind::Interrupted - | std_io::ErrorKind::TimedOut - | std_io::ErrorKind::WouldBlock - ) - }) - }) - } - - fn connect_record_at_version( + fn connect_record_at_version_until( record: TerminalHostRecord, record_path: PathBuf, - handshake_timeout: Duration, + deadline: Instant, protocol_version: u16, smart_renderer: bool, - mut stream: UnixStream, ) -> anyhow::Result { if !(LEGACY_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&protocol_version) { anyhow::bail!("unsupported terminal-host adoption protocol {protocol_version}"); @@ -2407,8 +3178,7 @@ mod unix { let terminal_id = TerminalId::from_bytes(decode_hex_array(&record.terminal_id)?); let incarnation = HostIncarnation::from_bytes(decode_hex_array(&record.incarnation)?); let owner_token = CapabilityToken::from_bytes(decode_hex_array(&record.owner_token)?); - stream.set_read_timeout(Some(handshake_timeout))?; - stream.set_write_timeout(Some(handshake_timeout))?; + let mut stream = connect_until(Path::new(&record.endpoint), deadline)?; let hello = ClientHello { min_version: protocol_version, max_version: protocol_version, @@ -2417,76 +3187,89 @@ mod unix { terminal_id, token: owner_token, }; - let mut hello_frame = hello.into_frame(1); - hello_frame.version = protocol_version; - if smart_renderer { - hello_frame.flags = FLAG_SMART_RENDERER | FLAG_VIEWER_SIZE_ACKS; - } - write_frame(&mut stream, &hello_frame)?; - let hello_frame = read_required_frame(&mut stream, "host hello")?; - if hello_frame.kind != MessageKind::HostHello - || hello_frame.version != protocol_version - || hello_frame.flags - & !(FLAG_VIEWER_SIZE_ACKS | FLAG_SMART_RENDERER | FLAG_LAUNCH_ACTIVATION_REQUIRED) - != 0 - || hello_frame.request_id != 1 - || hello_frame.sequence != 0 - || (smart_renderer && hello_frame.flags & FLAG_SMART_RENDERER == 0) - { - anyhow::bail!("terminal host rejected owner handshake"); - } - let launch_activation_pending = hello_frame.flags & FLAG_LAUNCH_ACTIVATION_REQUIRED != 0; - if launch_activation_pending && protocol_version < LAUNCH_ACTIVATION_PROTOCOL_VERSION { - anyhow::bail!("legacy terminal host requested launch activation"); - } - let host_hello = HostHello::decode(&hello_frame.payload)?; - if host_hello.selected_version != protocol_version - || host_hello.terminal_id != terminal_id - || host_hello.incarnation != incarnation - || host_hello.granted_rights != CapabilityRights::ADMIN - { - anyhow::bail!("terminal-host record identity does not match live host"); - } - let snapshot_frame = read_required_frame(&mut stream, "terminal snapshot")?; - if snapshot_frame.kind != MessageKind::Snapshot - || snapshot_frame.version != protocol_version - || snapshot_frame.flags != 0 - || snapshot_frame.request_id != 0 - { - anyhow::bail!("terminal host did not send an initial snapshot"); - } - let mut snapshot = decode_snapshot_for_version(&snapshot_frame.payload, protocol_version)?; - let colors_frame = read_required_frame(&mut stream, "terminal color state")?; - if colors_frame.kind != MessageKind::Colors - || colors_frame.version != protocol_version - || colors_frame.flags != 0 - || colors_frame.sequence != snapshot_frame.sequence - || colors_frame.request_id != 0 - { - anyhow::bail!("terminal host did not send Colors at the snapshot sequence boundary"); - } - snapshot.sequence_boundary = snapshot_frame.sequence; - snapshot.colors = decode_terminal_color_overrides(&colors_frame.payload)?; - if smart_renderer { - let ready_frame = read_required_frame(&mut stream, "terminal ready boundary")?; - if ready_frame.kind != MessageKind::Ready - || ready_frame.version != protocol_version - || ready_frame.flags != 0 - || ready_frame.sequence != snapshot_frame.sequence - || ready_frame.request_id != 0 - || !ready_frame.payload.is_empty() + let (snapshot, snapshot_size, launch_activation_pending) = { + let mut handshake = DeadlineStream::new(&mut stream, deadline); + let mut hello_frame = hello.into_frame(1); + hello_frame.version = protocol_version; + if smart_renderer { + hello_frame.flags = FLAG_SMART_RENDERER | FLAG_VIEWER_SIZE_ACKS; + } + write_frame(&mut handshake, &hello_frame)?; + let hello_frame = read_required_frame(&mut handshake, "host hello")?; + let allowed_flags = FLAG_VIEWER_SIZE_ACKS + | FLAG_SMART_RENDERER + | if protocol_version >= LAUNCH_ACTIVATION_PROTOCOL_VERSION { + FLAG_LAUNCH_ACTIVATION_REQUIRED + } else { + 0 + }; + if hello_frame.kind != MessageKind::HostHello + || hello_frame.version != protocol_version + || hello_frame.flags & !allowed_flags != 0 + || hello_frame.request_id != 1 + || hello_frame.sequence != 0 + || (smart_renderer && hello_frame.flags & FLAG_SMART_RENDERER == 0) + { + anyhow::bail!("terminal host rejected owner handshake"); + } + let launch_activation_pending = + hello_frame.flags & FLAG_LAUNCH_ACTIVATION_REQUIRED != 0; + let host_hello = HostHello::decode(&hello_frame.payload)?; + if host_hello.selected_version != protocol_version + || host_hello.terminal_id != terminal_id + || host_hello.incarnation != incarnation + || host_hello.granted_rights != CapabilityRights::ADMIN { - anyhow::bail!("terminal host did not send Ready at the snapshot sequence boundary"); + anyhow::bail!("terminal-host record identity does not match live host"); } - } - let snapshot_size = (snapshot.cols, snapshot.rows); + let snapshot_frame = read_required_frame(&mut handshake, "terminal snapshot")?; + if snapshot_frame.kind != MessageKind::Snapshot + || snapshot_frame.version != protocol_version + || snapshot_frame.flags != 0 + || snapshot_frame.request_id != 0 + { + anyhow::bail!("terminal host did not send an initial snapshot"); + } + let mut snapshot = + decode_snapshot_for_version(&snapshot_frame.payload, protocol_version)?; + let colors_frame = read_required_frame(&mut handshake, "terminal color state")?; + if colors_frame.kind != MessageKind::Colors + || colors_frame.version != protocol_version + || colors_frame.flags != 0 + || colors_frame.sequence != snapshot_frame.sequence + || colors_frame.request_id != 0 + { + anyhow::bail!( + "terminal host did not send Colors at the snapshot sequence boundary" + ); + } + snapshot.sequence_boundary = snapshot_frame.sequence; + snapshot.colors = decode_terminal_color_overrides(&colors_frame.payload)?; + if smart_renderer { + let ready_frame = read_required_frame(&mut handshake, "terminal ready boundary")?; + if ready_frame.kind != MessageKind::Ready + || ready_frame.version != protocol_version + || ready_frame.flags != 0 + || ready_frame.sequence != snapshot_frame.sequence + || ready_frame.request_id != 0 + || !ready_frame.payload.is_empty() + { + anyhow::bail!( + "terminal host did not send Ready at the snapshot sequence boundary" + ); + } + } + let snapshot_size = (snapshot.cols, snapshot.rows); + (snapshot, snapshot_size, launch_activation_pending) + }; stream.set_read_timeout(None)?; + stream.set_write_timeout(Some(HOST_HANDSHAKE_TIMEOUT))?; // Keep bounded writes for the lifetime of the disposable admin // mirror. A stopped or wedged host must not block a mux/control thread // forever while it sends input, mouse, resize, or Terminate. Reads are // unbounded because the dedicated reader thread is intentionally // long-lived and reconnects on any eventual EOF/protocol failure. - let reader = stream.try_clone()?; + let reader = cmux_tui_process::unix::clone_stream(&stream)?; let attachment = HostAttachment { record, record_path, @@ -2509,22 +3292,126 @@ mod unix { Ok(attachment) } - fn connect_with_retry(path: &Path) -> anyhow::Result { - let deadline = Instant::now() + HOST_CONNECT_RETRY_WINDOW; + struct DeadlineStream<'a> { + stream: &'a mut UnixStream, + deadline: Instant, + } + + impl<'a> DeadlineStream<'a> { + fn new(stream: &'a mut UnixStream, deadline: Instant) -> Self { + Self { stream, deadline } + } + + fn remaining(&self) -> std::io::Result { + self.deadline + .checked_duration_since(Instant::now()) + .filter(|value| !value.is_zero()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "terminal host operation exceeded its deadline", + ) + }) + } + + fn shutdown(&self, how: std::net::Shutdown) -> std::io::Result<()> { + self.stream.shutdown(how) + } + } + + impl Read for DeadlineStream<'_> { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + self.stream.set_read_timeout(Some(self.remaining()?))?; + self.stream.read(buffer) + } + } + + impl Write for DeadlineStream<'_> { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.stream.set_write_timeout(Some(self.remaining()?))?; + self.stream.write(buffer) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.stream.set_write_timeout(Some(self.remaining()?))?; + self.stream.flush() + } + } + + fn connect_until(path: &Path, deadline: Instant) -> anyhow::Result { + let mut last_error = None; loop { - match UnixStream::connect(path) { + if Instant::now() >= deadline { + return Err(last_error + .unwrap_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "terminal host connection exceeded its deadline", + ) + }) + .into()); + } + match connect_once_until(path, deadline) { Ok(stream) => return Ok(stream), - Err(error) => { - let now = Instant::now(); - if now >= deadline { - return Err(error.into()); - } - thread::sleep(HOST_CONNECT_RETRY_INTERVAL.min(deadline - now)); - } + Err(error) => last_error = Some(error), } + let now = Instant::now(); + if now >= deadline { + continue; + } + thread::sleep(deadline.saturating_duration_since(now).min(Duration::from_millis(10))); } } + fn connect_once_until(path: &Path, deadline: Instant) -> std::io::Result { + crate::platform::transport::connect_unix_until(path, deadline) + } + + #[cfg(all(test, target_os = "linux"))] + fn unix_socket_address(path: &Path) -> std::io::Result<(libc::sockaddr_un, libc::socklen_t)> { + const SUN_PATH_CAPACITY: usize = + size_of::() - offset_of!(libc::sockaddr_un, sun_path); + let path = path.as_os_str().as_bytes(); + if path.is_empty() || path.len() >= SUN_PATH_CAPACITY || path.contains(&0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid terminal host Unix socket path", + )); + } + // SAFETY: all-zero is a valid starting representation for + // sockaddr_un; family, path, and platform length are set below. + let mut address = unsafe { std::mem::zeroed::() }; + address.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (destination, source) in address.sun_path.iter_mut().zip(path) { + *destination = *source as libc::c_char; + } + let address_len = offset_of!(libc::sockaddr_un, sun_path) + path.len() + 1; + #[cfg(any( + target_os = "dragonfly", + target_os = "freebsd", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ))] + { + address.sun_len = u8::try_from(address_len).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "terminal host Unix socket path is too long", + ) + })?; + } + Ok(( + address, + libc::socklen_t::try_from(address_len).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "terminal host Unix socket path is too long", + ) + })?, + )) + } + fn read_required_frame(reader: &mut impl Read, context: &str) -> anyhow::Result { read_frame(reader, MAX_FRAME_PAYLOAD)? .ok_or_else(|| anyhow::anyhow!("terminal host closed before {context}")) @@ -2692,8 +3579,51 @@ mod unix { } fn prepare_private_dir(path: &Path) -> anyhow::Result<()> { - fs::create_dir_all(path)?; - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + match fs::create_dir(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let parent = path.parent().ok_or_else(|| { + anyhow::anyhow!("terminal-host directory has no parent: {}", path.display()) + })?; + fs::create_dir_all(parent)?; + match fs::create_dir(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + } + Err(error) => return Err(error.into()), + } + + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("open private terminal-host directory {}", path.display()))?; + let metadata = directory.metadata().with_context(|| { + format!("inspect private terminal-host directory {}", path.display()) + })?; + // SAFETY: geteuid has no preconditions and returns the effective user + // that must own this process-private endpoint directory. + let current_user = unsafe { libc::geteuid() }; + if !metadata.file_type().is_dir() || metadata.uid() != current_user { + anyhow::bail!( + "private terminal-host directory is not an owned directory: {}", + path.display() + ); + } + directory.set_permissions(fs::Permissions::from_mode(0o700))?; + + let current = fs::symlink_metadata(path).with_context(|| { + format!("reinspect private terminal-host directory {}", path.display()) + })?; + if !current.file_type().is_dir() + || current.dev() != metadata.dev() + || current.ino() != metadata.ino() + { + anyhow::bail!("private terminal-host directory changed: {}", path.display()); + } Ok(()) } @@ -3071,7 +4001,7 @@ mod unix { loop { if force_drain.load(Ordering::Acquire) { let started = forced_at.get_or_insert_with(Instant::now); - if started.elapsed() >= HOST_FORCED_DRAIN_WINDOW { + if started.elapsed() >= host_forced_drain_window() { return Ok(false); } } @@ -3089,7 +4019,7 @@ mod unix { ]; let timeout_ms = forced_at .map(|started| { - let remaining = HOST_FORCED_DRAIN_WINDOW.saturating_sub(started.elapsed()); + let remaining = host_forced_drain_window().saturating_sub(started.elapsed()); remaining.as_millis().clamp(1, i32::MAX as u128) as i32 }) .unwrap_or(-1); @@ -3126,6 +4056,96 @@ mod unix { } } + fn wait_for_host_service_activity( + listener_fd: RawFd, + exit_waiter: &mut UnixStream, + dead: &AtomicBool, + deadline: Option, + ) -> std::io::Result { + loop { + if dead.load(Ordering::Acquire) { + return Ok(false); + } + let mut poll_fds = [ + libc::pollfd { + fd: listener_fd, + events: libc::POLLIN | libc::POLLHUP | libc::POLLERR, + revents: 0, + }, + libc::pollfd { + fd: exit_waiter.as_raw_fd(), + events: libc::POLLIN | libc::POLLHUP | libc::POLLERR, + revents: 0, + }, + ]; + let timeout = deadline + .map(|deadline| { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + 0 + } else { + remaining.as_millis().clamp(1, i32::MAX as u128) as i32 + } + }) + .unwrap_or(-1); + // SAFETY: poll_fds contains two initialized descriptors that + // remain owned by the service loop for the duration of this call. + let ready = unsafe { + libc::poll(poll_fds.as_mut_ptr(), poll_fds.len() as libc::nfds_t, timeout) + }; + if ready < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } + if ready == 0 { + return Ok(true); + } + if poll_fds[0].revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::other("terminal host listener became invalid")); + } + if poll_fds[1].revents & libc::POLLIN != 0 { + let mut wake = [0u8; 64]; + let _ = exit_waiter.read(&mut wake); + if dead.load(Ordering::Acquire) { + return Ok(false); + } + } + if poll_fds[0].revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 { + return Ok(true); + } + if poll_fds[1].revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 { + if dead.load(Ordering::Acquire) { + return Ok(false); + } + return Err(std::io::Error::other("terminal host exit waiter became invalid")); + } + } + } + + struct HostClientAdmission { + active: Arc, + } + + impl HostClientAdmission { + fn try_acquire(active: &Arc) -> Option { + active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { + (count < MAX_TERMINAL_HOST_CLIENTS).then_some(count + 1) + }) + .ok()?; + Some(Self { active: active.clone() }) + } + } + + impl Drop for HostClientAdmission { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } + } + struct HostShared { terminal_id: TerminalId, incarnation: HostIncarnation, @@ -3144,6 +4164,7 @@ mod unix { cell_pixels: Mutex<(u16, u16)>, viewer_sizes: Mutex>, taps: Mutex>, + taps_changed: Condvar, broadcast_lock: Mutex<()>, sequence: AtomicU64, smart: SmartStreamState, @@ -3158,6 +4179,7 @@ mod unix { /// without blocking the reader from enqueueing the completing bytes. parser_progress: (Mutex, Condvar), next_client: AtomicU64, + active_clients: Arc, dead: AtomicBool, launch_owner_claimed: AtomicBool, launch_owner_stream_ready: AtomicBool, @@ -3171,11 +4193,18 @@ mod unix { exit_publish_requests: Sender<()>, force_pty_drain: AtomicBool, pty_drain_waker: Mutex, + service_exit_waker: Mutex, + service_exit_waiter: Mutex>, termination_started: AtomicBool, child_signal_lock: Mutex<()>, child_reaped: AtomicBool, + child_wait_ownership_lost: AtomicBool, group_escalation_complete: AtomicBool, #[cfg(test)] + normal_cleanup_failures: AtomicUsize, + #[cfg(test)] + normal_cleanup_attempts: AtomicUsize, + #[cfg(test)] fail_next_resize_publication: AtomicBool, } @@ -3594,7 +4623,9 @@ mod unix { } fn remove_client(&self, client: u64) { - self.taps.lock().unwrap().remove(&client); + if self.taps.lock().unwrap().remove(&client).is_some() { + self.taps_changed.notify_all(); + } self.smart.remove(client); let _ = mutate_viewer_sizes( &self.viewer_sizes, @@ -4128,14 +5159,37 @@ mod unix { self.pty_drained.load(Ordering::Acquire) } - fn publish_child_wait_predicate(&self, predicate: &AtomicBool) { - // Every predicate consumed by child_exit.wait_* must change while + fn observe_child_wait_state_locked( + &self, + session: libc::pid_t, + nonblocking: bool, + ) -> std::io::Result { + if self.child_wait_ownership_lost.load(Ordering::Acquire) { + return Ok(crate::process_session::ChildWaitState::OwnershipLost); + } + let state = + crate::process_session::observe_child_without_reaping(session, nonblocking)?; + match state { + crate::process_session::ChildWaitState::Waitable => { + self.mark_child_waitable(); + } + crate::process_session::ChildWaitState::OwnershipLost => { + self.publish_child_wait_predicate(&self.child_wait_ownership_lost); + } + crate::process_session::ChildWaitState::Running => {} + } + Ok(state) + } + + fn publish_child_wait_predicate(&self, predicate: &AtomicBool) -> bool { + // Every predicate consumed by child_exit.wait_* changes while // holding this mutex. Otherwise a notifier can run after a waiter // checks the atomic but before Condvar::wait arms, losing the only // wake that allows the terminal exit to be published. let _state = self.child_exit.0.lock().unwrap(); - predicate.store(true, Ordering::Release); + let changed = !predicate.swap(true, Ordering::AcqRel); self.child_exit.1.notify_all(); + changed } fn mark_child_waitable(&self) { @@ -4143,42 +5197,86 @@ mod unix { } fn mark_pty_drained(&self) { - self.publish_child_wait_predicate(&self.pty_drained); + if self.publish_child_wait_predicate(&self.pty_drained) { + self.wake_reserved_child_reaper(); + } } - fn signal_terminal_process_groups(&self, signal: libc::c_int) { - let mut groups = Vec::with_capacity(2); - // The wait thread observes exit with WNOWAIT, then takes this lock - // before reaping. While we hold it, `!child_reaped` means the - // original PID/PGID is still kernel-reserved and cannot have been - // reused between validation and killpg. + fn signal_terminal_process_session( + &self, + signal: libc::c_int, + deadline: Instant, + ) -> std::io::Result<()> { + // The shared reaper observes exit with WNOWAIT, then takes this + // lock before reaping. Holding it reserves the session leader and + // its numeric session id across enumeration and signaling. let _signal = self.child_signal_lock.lock().unwrap(); - let child_reserved = !self.child_reaped.load(Ordering::Acquire); - if child_reserved - && let Some(pid) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) - { - groups.push(pid); + let Some(session) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + return Err(std::io::Error::other("PTY child has no process id")); + }; + if self.child_wait_ownership_lost.load(Ordering::Acquire) { + return Err(std::io::Error::other("PTY child wait ownership was lost")); + } + if self.child_reaped.load(Ordering::Acquire) { + return match crate::process_session::session_is_empty_until(session, deadline) { + Ok(true) => Ok(()), + Ok(false) => Err(std::io::Error::other( + "PTY session still has members after its leader was reaped", + )), + Err(error) => Err(error), + }; } - // Query the PTY each time rather than trusting the original group: - // a foreground job or retained descendant may own a different - // group by the time explicit Terminate escalates. - if child_reserved - && let Some(foreground) = self.master.lock().unwrap().process_group_leader() + if self.observe_child_wait_state_locked(session, true)? + == crate::process_session::ChildWaitState::OwnershipLost { - groups.push(foreground); + return Err(std::io::Error::other("PTY child wait ownership was lost")); + } + crate::process_session::signal_until(session, signal, deadline) + } + + fn kill_terminal_process_session_until(&self, deadline: Instant) -> std::io::Result { + let _signal = self.child_signal_lock.lock().unwrap(); + let Some(leader) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + return Err(std::io::Error::other("PTY child has no process id")); + }; + if self.child_wait_ownership_lost.load(Ordering::Acquire) { + return Err(std::io::Error::other("PTY child wait ownership was lost")); + } + if self.child_reaped.load(Ordering::Acquire) { + return crate::process_session::session_is_empty_until(leader, deadline); } - groups.sort_unstable(); - groups.dedup(); - // A portable-pty child starts as a new session/process-group - // leader. Signal both that durable group and any foreground job - // group, but never risk addressing the terminal-host's own group. - // SAFETY: getpgrp has no preconditions. - let host_group = unsafe { libc::getpgrp() }; - for group in groups.into_iter().filter(|group| *group > 0 && *group != host_group) { - // SAFETY: validated positive process-group ids owned by this - // PTY session; signal is a platform constant from this module. - let _ = unsafe { libc::killpg(group, signal) }; + if self.observe_child_wait_state_locked(leader, true)? + == crate::process_session::ChildWaitState::OwnershipLost + { + return Err(std::io::Error::other("PTY child wait ownership was lost")); + } + crate::process_session::kill_until_only_leader(leader, leader, deadline, || match self + .observe_child_wait_state_locked( + leader, true, + )? { + crate::process_session::ChildWaitState::Running => Ok(false), + crate::process_session::ChildWaitState::Waitable => Ok(true), + crate::process_session::ChildWaitState::OwnershipLost => { + Err(std::io::Error::other("PTY child wait ownership was lost")) + } + }) + } + + fn prepare_natural_cleanup(&self) -> bool { + #[cfg(test)] + { + self.normal_cleanup_attempts.fetch_add(1, Ordering::AcqRel); + if self + .normal_cleanup_failures + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return false; + } } + true } fn request_forced_pty_drain(&self) { @@ -4188,6 +5286,13 @@ mod unix { let _ = self.pty_drain_waker.lock().unwrap().write_all(&[1]); } + fn wake_reserved_child_reaper(&self) { + let Some(session) = self.pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + return; + }; + crate::process_session::wake_child_reaper(session); + } + fn request_termination(self: &Arc) { let already_started = { // Serialize the ownership transition with WNOWAIT's final @@ -4196,9 +5301,11 @@ mod unix { let _signal = self.child_signal_lock.lock().unwrap(); self.termination_started.swap(true, Ordering::AcqRel) }; + self.child_exit.1.notify_all(); if already_started { return; } + self.wake_reserved_child_reaper(); let worker = self.clone(); if thread::Builder::new() .name("terminal-host-terminate".into()) @@ -4212,7 +5319,17 @@ mod unix { } fn finish_group_escalation(&self) { - self.publish_child_wait_predicate(&self.group_escalation_complete); + if self.publish_child_wait_predicate(&self.group_escalation_complete) { + self.wake_reserved_child_reaper(); + } + } + + fn abandon_group_escalation(&self) { + let changed = self.termination_started.swap(false, Ordering::AcqRel); + self.child_exit.1.notify_all(); + if changed { + self.wake_reserved_child_reaper(); + } } fn publish_exit_if_drained(&self) { @@ -4313,34 +5430,103 @@ mod unix { self.broadcast(MessageKind::Exit, payload); } self.note_parser_progress(); + // Wake the blocking listener poll after durable exit publication. + let _ = self.service_exit_waker.lock().unwrap().write_all(&[1]); } Ok(()) } + fn drain_clients_after_exit(&self) { + let deadline = Instant::now() + HOST_CLIENT_EXIT_DRAIN; + let mut taps = self.taps.lock().unwrap(); + while !taps.is_empty() { + let now = Instant::now(); + if now >= deadline { + break; + } + let (next, timeout) = self.taps_changed.wait_timeout(taps, deadline - now).unwrap(); + taps = next; + if timeout.timed_out() && !taps.is_empty() { + break; + } + } + let stalled = taps.values().cloned().collect::>(); + drop(taps); + for tap in stalled { + tap.close(); + } + } + fn terminate_and_wait(&self) { - { + let (termination_changed, child_reaped, wait_ownership_lost) = { let _signal = self.child_signal_lock.lock().unwrap(); - self.termination_started.store(true, Ordering::Release); + let termination_changed = !self.termination_started.swap(true, Ordering::AcqRel); + ( + termination_changed, + self.child_reaped.load(Ordering::Acquire), + self.child_wait_ownership_lost.load(Ordering::Acquire), + ) + }; + self.child_exit.1.notify_all(); + if termination_changed { + self.wake_reserved_child_reaper(); + } + if wait_ownership_lost { + self.abandon_group_escalation(); + return; + } + if child_reaped { + let session_empty = self + .pid + .and_then(|pid| libc::pid_t::try_from(pid).ok()) + .is_some_and(|session| { + matches!( + crate::process_session::session_is_empty_until( + session, + Instant::now() + HOST_KILL_WAIT, + ), + Ok(true) + ) + }); + if session_empty { + self.child_exit.1.notify_all(); + self.publish_exit_if_drained(); + } else { + self.abandon_group_escalation(); + } + return; } // ProcessSignaller only targets the direct child. Start with a - // graceful group hangup so foreground jobs and normal descendants - // can clean up too, then escalate after a strict bound. - self.signal_terminal_process_groups(libc::SIGHUP); + // graceful session hangup so foreground and background jobs can + // clean up too, then escalate after a strict bound. + if self + .signal_terminal_process_session( + libc::SIGHUP, + Instant::now() + HOST_TERMINATE_GRACE, + ) + .is_err() + { + self.abandon_group_escalation(); + return; + } if !self.child_waitable.load(Ordering::Acquire) { let _ = self.killer.lock().unwrap().kill(); } let _ = self.wait_for_child_waitable(HOST_TERMINATE_GRACE); let _ = self.wait_for_pty_drain(HOST_PTY_DRAIN_GRACE); - // The direct child may ignore SIGHUP, or it may already have - // exited while a descendant retains the PTY. Kill both the - // original session group and its current foreground job group. - // This escalation is mandatory even if Darwin reports PTY EOF as - // soon as the session leader exits: an HUP-ignoring descendant - // can still be alive in the now-invisible original group. - self.signal_terminal_process_groups(libc::SIGKILL); + // Process groups are transient job-control details. Repeatedly + // kill every member of the PTY session while WNOWAIT reserves the + // leader, then publish Exit only after every background group is + // gone. + let kill_deadline = Instant::now() + HOST_KILL_WAIT; + if !matches!(self.kill_terminal_process_session_until(kill_deadline), Ok(true)) { + self.abandon_group_escalation(); + return; + } self.finish_group_escalation(); - let child_exited = self.wait_for_child_exit(HOST_KILL_WAIT); + let child_exited = + self.wait_for_child_exit(kill_deadline.saturating_duration_since(Instant::now())); if child_exited && self.wait_for_pty_drain(HOST_PTY_DRAIN_GRACE) { return; } @@ -4351,7 +5537,7 @@ mod unix { // the durable host: wake the reader, drain bytes already // readable for a short bounded window, then publish Exit. self.request_forced_pty_drain(); - let _ = self.wait_for_pty_drain(HOST_FORCED_DRAIN_WINDOW * 2); + let _ = self.wait_for_pty_drain(host_forced_drain_window() * 2); } } } @@ -4378,6 +5564,30 @@ mod unix { .then_some(exit)) } + fn reap_reserved_child_status(session: libc::pid_t) -> TerminalExit { + use std::os::unix::process::ExitStatusExt; + + loop { + let mut status = 0; + // SAFETY: the shared reaper calls this while holding the signal + // lock after WNOWAIT proved `session` is our waitable child. The + // retained child handle keeps the numeric PID reserved until this + // sole final reap completes. + let waited = unsafe { libc::waitpid(session, &mut status, 0) }; + if waited == session { + return TerminalExit::from_exit_status(&std::process::ExitStatus::from_raw(status)); + } + if waited < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return TerminalExit::unknown(format!("waitpid failed: {error}")); + } + return TerminalExit::unknown("waitpid returned without the reserved child"); + } + } + /// Keep viewer mutation, minimum reduction, and the resulting PTY resize /// in one critical section. If the guard were released after reduction, /// an older large resize could run after a newer small resize and leave @@ -4401,31 +5611,6 @@ mod unix { Ok(()) } - fn wait_for_child_exit_without_reaping(pid: libc::pid_t) -> std::io::Result<()> { - loop { - let mut status = std::mem::MaybeUninit::::uninit(); - // SAFETY: status points to writable siginfo storage. WNOWAIT - // observes this owned child becoming waitable without releasing - // its PID/PGID for reuse; the portable Child handle reaps it after - // acquiring child_signal_lock. - let result = unsafe { - libc::waitid( - libc::P_PID, - pid as libc::id_t, - status.as_mut_ptr(), - libc::WEXITED | libc::WNOWAIT, - ) - }; - if result == 0 { - return Ok(()); - } - let error = std::io::Error::last_os_error(); - if error.kind() != std::io::ErrorKind::Interrupted { - return Err(error); - } - } - } - struct HostLivenessLease { file: File, path: PathBuf, @@ -4454,13 +5639,17 @@ mod unix { impl Drop for HostLivenessLease { fn drop(&mut self) { - // Closing the owner's descriptor does not release flock while a - // concurrently forked child still holds an inherited duplicate. - // The lease lifetime belongs to this owner, so end it explicitly - // before closing the descriptor. - // SAFETY: flock only changes the advisory lock associated with - // this valid, owned file descriptor. - let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) }; + loop { + // SAFETY: this valid descriptor owns the advisory lock. An + // explicit unlock also releases the shared open-file + // description inherited by a child between fork and exec. + if unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) } == 0 { + break; + } + if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted { + break; + } + } } } @@ -4604,7 +5793,7 @@ mod unix { endpoint: PathBuf, record_path: PathBuf, record: TerminalHostRecord, - lease: Option, + lease: HostLivenessLease, published: bool, } @@ -4643,37 +5832,60 @@ mod unix { && current.incarnation == self.record.incarnation && current.host_start_nonce == self.record.host_start_nonce }); - let released_lease_path = if owns_record { - self.lease.take().map(|lease| { - let _ = lease.file.sync_all(); - let path = lease.path.clone(); - // Unlock the process-incarnation proof before removing its - // discovery record. Observers can never see an absent - // record whose captured liveness proof still says Live. - drop(lease); - path - }) + let endpoint_removed = match fs::remove_file(&self.endpoint) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(_) => false, + }; + let lease_removed = if owns_record { + match fs::remove_file(&self.lease.path) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(_) => false, + } } else { - None + false }; - let removed_record = - !self.published || (owns_record && fs::remove_file(&self.record_path).is_ok()); - let _ = fs::remove_file(&self.endpoint); - if removed_record && let Some(path) = released_lease_path { - let _ = fs::remove_file(path); + let _ = self.lease.file.sync_all(); + // Record removal is the final cleanup barrier. Once observers see + // it absent, both the transport endpoint and process-bound + // liveness path are already gone. + if self.published && owns_record && endpoint_removed && lease_removed { + let _ = fs::remove_file(&self.record_path); } } } + #[cfg(debug_assertions)] + fn wait_for_test_host_gate(signal_name: &str, gate_name: &str) -> anyhow::Result<()> { + if let Some(signal) = std::env::var_os(signal_name) { + OpenOptions::new().write(true).open(signal)?.write_all(b"1")?; + } + if let Some(gate) = std::env::var_os(gate_name) { + let mut gate = File::open(gate)?; + let mut release = [0_u8; 1]; + gate.read_exact(&mut release)?; + } + Ok(()) + } + pub fn serve_terminal_host_stdio( args: &[String], reader: &mut impl Read, writer: &mut impl Write, ) -> anyhow::Result<()> { - if args.iter().map(String::as_str).ne(["--bootstrap-stdio"]) { - anyhow::bail!("hidden mode requires --bootstrap-stdio"); - } + let publication_descriptor = terminal_host_publication_descriptor(args)?; + #[cfg(debug_assertions)] + wait_for_test_host_gate( + "CMUX_TUI_TEST_BOOTSTRAP_READY_STARTED_SIGNAL", + "CMUX_TUI_TEST_BOOTSTRAP_READY_GATE", + )?; let bootstrapped = crate::terminal_host::bootstrap_stdio_once(reader, writer)?; + #[cfg(debug_assertions)] + wait_for_test_host_gate( + "CMUX_TUI_TEST_LAUNCH_READ_STARTED_SIGNAL", + "CMUX_TUI_TEST_LAUNCH_READ_GATE", + )?; let Some(launch_frame) = read_frame(reader, MAX_LAUNCH_PAYLOAD)? else { // Keep the one-frame bootstrap probe useful for compatibility and // packaging diagnostics. Production launchers always follow it @@ -4684,6 +5896,18 @@ mod unix { anyhow::bail!("expected terminal-host Launch, received {:?}", launch_frame.kind); } let launch = HostLaunch::decode(&launch_frame.payload)?; + let publication_descriptor = publication_descriptor + .ok_or_else(|| anyhow::anyhow!("terminal-host launch omitted publication ownership"))?; + // SAFETY: the private launcher passes one unique child descriptor, + // and fd isolation preserves only that descriptor beyond stdio. + let publication_guard = unsafe { + crate::PublicationGuard::adopt_inherited( + Path::new(&launch.record_path), + publication_descriptor, + Instant::now() + HOST_LAUNCH_TIMEOUT, + ) + } + .context("adopt terminal-host record publication ownership")?; let shared = match spawn_host_runtime(&launch, &bootstrapped) { Ok(shared) => shared, Err(error) => { @@ -4706,7 +5930,7 @@ mod unix { if let Some(parent) = endpoint.parent() { prepare_private_dir(parent)?; } - let listener = UnixListener::bind(&endpoint)?; + let listener = cmux_tui_process::unix::bind_listener(&endpoint)?; fs::set_permissions(&endpoint, fs::Permissions::from_mode(0o600))?; listener.set_nonblocking(true)?; @@ -4721,6 +5945,7 @@ mod unix { host_start_nonce: encode_hex(start_nonce.as_bytes()), workspace_key: String::new(), supports_set_defaults: true, + supports_terminate_only: true, supports_clear_history: true, supports_terminate_ack: true, }; @@ -4735,7 +5960,7 @@ mod unix { endpoint, record_path: PathBuf::from(&launch.record_path), record: record.clone(), - lease: Some(lease), + lease, published: false, }; unpublished.armed = false; @@ -4745,6 +5970,7 @@ mod unix { // leave behind an undiscoverable terminal process. write_record(Path::new(&launch.record_path), &record)?; guard.published = true; + drop(publication_guard); // Integration failure-injection seam for the narrow record-before- // Ready crash window. It is inherited only by explicitly configured @@ -4763,6 +5989,9 @@ mod unix { incarnation: bootstrapped.incarnation, }; let mut response = Frame::new(MessageKind::Ready, ready.encode()); + if std::env::var("CMUX_TUI_TEST_INVALID_HOST_READY").as_deref() == Ok("1") { + response.kind = MessageKind::Snapshot; + } response.request_id = launch_frame.request_id; // Publication is the ownership handoff. If the launcher dies in the // narrow record-before-Ready window, EPIPE must not tear down the @@ -4771,6 +6000,10 @@ mod unix { let _ = write_frame(writer, &response); let launch_owner_deadline = Instant::now() + HOST_LAUNCH_OWNER_TIMEOUT; + let mut service_waiter = + shared.service_exit_waiter.lock().unwrap().take().ok_or_else(|| { + anyhow::anyhow!("terminal host service waiter was already claimed") + })?; loop { let now = Instant::now(); if !shared.launch_owner_claimed.load(Ordering::Acquire) @@ -4785,32 +6018,47 @@ mod unix { // adoptable; only its eventual exit is now unblocked. shared.mark_launch_owner_stream_ready(); } - if shared.dead.load(Ordering::Acquire) - && shared.active_client_streams.load(Ordering::Acquire) == 0 - { + if shared.dead.load(Ordering::Acquire) { break; } - match listener.accept() { + match cmux_tui_process::unix::accept_stream(&listener) { Ok((stream, _)) => { // Accepted sockets inherit O_NONBLOCK from the listener // on macOS. Client protocol threads use blocking framed // reads, so normalize the accepted descriptor here. stream.set_nonblocking(false)?; + let Some(admission) = HostClientAdmission::try_acquire(&shared.active_clients) + else { + continue; + }; let host = shared.clone(); - thread::Builder::new().name("terminal-host-client".into()).spawn( + // A transient thread creation failure rejects this client + // without taking down the terminal host. Dropping the + // unstarted closure releases its admission automatically. + let _ = thread::Builder::new().name("terminal-host-client".into()).spawn( move || { + let _admission = admission; let _ = serve_client(host, stream); }, - )?; + ); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(20)); + let deadline = (!shared.launch_owner_claimed.load(Ordering::Acquire)) + .then_some(launch_owner_deadline); + if !wait_for_host_service_activity( + listener.as_raw_fd(), + &mut service_waiter, + &shared.dead, + deadline, + )? { + break; + } } Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} Err(error) => return Err(error.into()), } } - thread::sleep(Duration::from_millis(20)); + shared.drain_clients_after_exit(); drop(guard); Ok(()) } @@ -4830,6 +6078,8 @@ mod unix { launch: &HostLaunch, bootstrapped: &crate::terminal_host::BootstrappedHost, ) -> anyhow::Result> { + let reaper = crate::process_session::reserve_child_reaper() + .context("reserve bounded PTY session cleanup")?; let cell_pixels = (launch.cell_pixels.0.max(1), launch.cell_pixels.1.max(1)); let initial_pty_size = pty_size(launch.cols, launch.rows, cell_pixels)?; let pty = cmux_pty::open(initial_pty_size)?; @@ -4839,16 +6089,33 @@ mod unix { for (key, value) in &launch.extra_env { command.env(key, value); } + command.env_remove(crate::release::LAUNCHER_COMMAND_ENV); if let Some(cwd) = launch.cwd.as_deref() { command.cwd(cwd); } - let cmux_pty::SpawnedPty { master, mut child } = pty.spawn(command)?; + let cmux_pty::SpawnedPty { master, child } = pty.spawn(command)?; + let child = crate::spawned_pty_child::SpawnedPtyChild::new(child).with_reaper(reaper); + #[cfg(test)] + crate::process_session::fail_after_pty_spawn_for_test()?; let pid = child.process_id(); + let Some(session) = pid.and_then(|pid| libc::pid_t::try_from(pid).ok()) else { + anyhow::bail!("native terminal-host PTY child did not expose a process ID"); + }; + let child_identity = match crate::process_session::StableProcessHandle::capture(session) { + Ok(Some(identity)) => identity, + Ok(None) => { + anyhow::bail!( + "terminal-host PTY child exited before its process identity was captured" + ); + } + Err(error) => return Err(error).context("capture terminal-host PTY child identity"), + }; let killer = child.clone_killer(); let pty_poll_fd = master.as_raw_fd().context("open terminal-host PTY poll fd")?; let mut pty_reader = master.try_clone_reader()?; let pty_writer = master.take_writer()?; - let (pty_drain_waker, pty_drain_waiter) = UnixStream::pair()?; + let (pty_drain_waker, pty_drain_waiter) = cmux_tui_process::unix::pair_stream()?; + let (service_exit_waker, service_exit_waiter) = cmux_tui_process::unix::pair_stream()?; let pending_responses = Arc::new(Mutex::new(Vec::::new())); let title_changed = Arc::new(AtomicBool::new(false)); @@ -4898,6 +6165,7 @@ mod unix { cell_pixels: Mutex::new(cell_pixels), viewer_sizes: Mutex::new(HashMap::new()), taps: Mutex::new(HashMap::new()), + taps_changed: Condvar::new(), broadcast_lock: Mutex::new(()), sequence: AtomicU64::new(0), smart: SmartStreamState::new(), @@ -4906,6 +6174,7 @@ mod unix { parser_budget: ParserBudget::new(MAX_HOST_PARSER_QUEUED_BYTES), parser_progress: (Mutex::new(0), Condvar::new()), next_client: AtomicU64::new(1), + active_clients: Arc::new(AtomicUsize::new(0)), dead: AtomicBool::new(false), launch_owner_claimed: AtomicBool::new(false), launch_owner_stream_ready: AtomicBool::new(false), @@ -4919,11 +6188,20 @@ mod unix { exit_publish_requests, force_pty_drain: AtomicBool::new(false), pty_drain_waker: Mutex::new(pty_drain_waker), + service_exit_waker: Mutex::new(service_exit_waker), + service_exit_waiter: Mutex::new(Some(service_exit_waiter)), termination_started: AtomicBool::new(false), child_signal_lock: Mutex::new(()), child_reaped: AtomicBool::new(false), + child_wait_ownership_lost: AtomicBool::new(false), group_escalation_complete: AtomicBool::new(false), #[cfg(test)] + normal_cleanup_failures: AtomicUsize::new( + NEXT_HOST_NORMAL_CLEANUP_FAILURES.swap(0, Ordering::AcqRel), + ), + #[cfg(test)] + normal_cleanup_attempts: AtomicUsize::new(0), + #[cfg(test)] fail_next_resize_publication: AtomicBool::new(false), }); HostShared::start_exit_publisher(&shared, exit_publish_receiver)?; @@ -5078,54 +6356,92 @@ mod unix { let _source_order = reader_host.source_order_lock.lock().unwrap(); let _ = reader_host.parser_commands.send(ParserCommand::Drain); })?; - let child_host = shared.clone(); - thread::Builder::new().name("terminal-host-child".into()).spawn(move || { - let observed_without_reaping = child_host - .pid - .and_then(|pid| libc::pid_t::try_from(pid).ok()) - .is_some_and(|pid| wait_for_child_exit_without_reaping(pid).is_ok()); - if observed_without_reaping { - child_host.mark_child_waitable(); - loop { - let signal = child_host.child_signal_lock.lock().unwrap(); - let escalation_complete = - child_host.group_escalation_complete.load(Ordering::Acquire); - let termination_started = - child_host.termination_started.load(Ordering::Acquire); - let pty_drained = child_host.pty_drained.load(Ordering::Acquire); - if escalation_complete || (!termination_started && pty_drained) { - let exit = wait_for_native_child_status(child.as_mut()); - child_host.child_reaped.store(true, Ordering::Release); - drop(signal); - *child_host.child_exit.0.lock().unwrap() = Some(exit); - break; + let observe_host = shared.clone(); + let cleanup_host = shared.clone(); + let prepare_host = shared.clone(); + let reap_host = shared.clone(); + let mut child = child; + let reaper = child + .take_reaper() + .expect("terminal-host PTY child retains its reserved session reaper"); + let mut child = Some(child); + crate::process_session::enqueue_reserved_session_leader( + reaper.attach_owner(), + session, + HOST_KILL_WAIT, + move || { + let _signal = observe_host.child_signal_lock.lock().unwrap(); + let state = observe_host.observe_child_wait_state_locked(session, true)?; + Ok(state != crate::process_session::ChildWaitState::Running) + }, + move || { + crate::process_session::reserved_child_needs_cleanup( + crate::process_session::ReservedChildReap { + signal_lock: &cleanup_host.child_signal_lock, + pty_drained: &cleanup_host.pty_drained, + termination_started: &cleanup_host.termination_started, + cleanup_complete: &cleanup_host.group_escalation_complete, + child_reaped: &cleanup_host.child_reaped, + wait_ownership_lost: &cleanup_host.child_wait_ownership_lost, + }, + ) + }, + move || prepare_host.prepare_natural_cleanup(), + move |cleanup_succeeded| { + if reap_host.child_reaped.load(Ordering::Acquire) { + reap_host.publish_exit_if_drained(); + return crate::process_session::NaturalReapFinish::Complete; + } + if reap_host.child_wait_ownership_lost.load(Ordering::Acquire) { + let _signal = reap_host.child_signal_lock.lock().unwrap(); + if !reap_host.pty_drained.load(Ordering::Acquire) { + return crate::process_session::NaturalReapFinish::Pending; } - drop(signal); - let state = child_host.child_exit.0.lock().unwrap(); - let _state = child_host - .child_exit - .1 - .wait_while(state, |_| { - !child_host.group_escalation_complete.load(Ordering::Acquire) - && (child_host.termination_started.load(Ordering::Acquire) - || !child_host.pty_drained.load(Ordering::Acquire)) - }) - .unwrap(); + match child_identity.matches_current() { + Ok(false) => {} + Ok(true) | Err(_) => { + return crate::process_session::NaturalReapFinish::Failed; + } + } + child + .take() + .expect("reserved child releases lost wait ownership once") + .abandon_wait_ownership(); + *reap_host.child_exit.0.lock().unwrap() = Some(TerminalExit::unknown( + "terminal child wait ownership was lost before native status was available", + )); + reap_host.child_reaped.store(true, Ordering::Release); + drop(_signal); + reap_host.child_exit.1.notify_all(); + reap_host.publish_exit_if_drained(); + return crate::process_session::NaturalReapFinish::Complete; } - child_host.child_exit.1.notify_all(); - child_host.publish_exit_if_drained(); - } else { - // Native Unix PTYs always expose a PID and support waitid; - // retain a conservative fallback for alternate backends. - let exit = wait_for_native_child_status(child.as_mut()); - child_host.child_reaped.store(true, Ordering::Release); - child_host.mark_child_waitable(); - let mut exited = child_host.child_exit.0.lock().unwrap(); - *exited = Some(exit); - child_host.child_exit.1.notify_all(); - child_host.publish_exit_if_drained(); - } - })?; + let done = crate::process_session::poll_reserved_session_leader( + crate::process_session::ReservedChildReap { + signal_lock: &reap_host.child_signal_lock, + pty_drained: &reap_host.pty_drained, + termination_started: &reap_host.termination_started, + cleanup_complete: &reap_host.group_escalation_complete, + child_reaped: &reap_host.child_reaped, + wait_ownership_lost: &reap_host.child_wait_ownership_lost, + }, + cleanup_succeeded, + || { + let child = child.take().expect("reserved child is reaped once"); + let exit = reap_reserved_child_status(session); + child.abandon_wait_ownership(); + *reap_host.child_exit.0.lock().unwrap() = Some(exit); + reap_host.child_exit.1.notify_all(); + }, + ); + if done { + reap_host.publish_exit_if_drained(); + crate::process_session::NaturalReapFinish::Complete + } else { + crate::process_session::NaturalReapFinish::Pending + } + }, + ); Ok(shared) } @@ -5148,21 +6464,29 @@ mod unix { mut stream: UnixStream, snapshot_timeout: Duration, ) -> anyhow::Result<()> { + stream.set_read_timeout(Some(HOST_HANDSHAKE_TIMEOUT))?; // A client that stops reading must not retain an exited host forever. // Bound the actual stalled resource instead of imposing a wall-clock // deadline on healthy clients that are still draining sequenced bytes. stream.set_write_timeout(Some(HOST_CLIENT_WRITE_TIMEOUT))?; let hello_frame = read_required_frame(&mut stream, "client hello")?; + let known_flags = FLAG_VIEWER_SIZE_ACKS | FLAG_SMART_RENDERER | FLAG_TERMINATE_ONLY; if hello_frame.kind != MessageKind::ClientHello || hello_frame.sequence != 0 - || hello_frame.flags & !(FLAG_VIEWER_SIZE_ACKS | FLAG_SMART_RENDERER) != 0 + || hello_frame.flags & !known_flags != 0 + || (hello_frame.flags & FLAG_TERMINATE_ONLY != 0 + && hello_frame.flags != FLAG_TERMINATE_ONLY) { anyhow::bail!("terminal-host client did not send ClientHello"); } let hello = ClientHello::decode(&hello_frame.payload)?; let response = authenticate_client(&host, &hello)?; + let terminate_only = hello_frame.flags == FLAG_TERMINATE_ONLY; if hello_frame.version != response.selected_version - || !response.granted_rights.contains(CapabilityRights::READ) + || (!terminate_only && !response.granted_rights.contains(CapabilityRights::READ)) + || (terminate_only + && (hello.role != ClientRole::Admin + || response.granted_rights != CapabilityRights::TERMINATE)) { anyhow::bail!("terminal-host capability denied"); } @@ -5182,6 +6506,9 @@ mod unix { if viewer_size_acks { hello_response.flags |= FLAG_VIEWER_SIZE_ACKS; } + if terminate_only { + hello_response.flags |= FLAG_TERMINATE_ONLY; + } if activation_required { hello_response.flags |= FLAG_LAUNCH_ACTIVATION_REQUIRED; } @@ -5191,12 +6518,32 @@ mod unix { hello_response.request_id = hello_frame.request_id; write_frame(&mut stream, &hello_response)?; + if terminate_only { + let terminate = read_required_frame(&mut stream, "terminate-only request")?; + if terminate.kind != MessageKind::Terminate + || terminate.flags != 0 + || terminate.sequence != 0 + || terminate.request_id != 0 + || !terminate.payload.is_empty() + { + anyhow::bail!("terminate-only client sent an unexpected request"); + } + host.request_termination(); + return Ok(()); + } + + // Authenticated interactive clients are long-lived. Clear the + // unauthenticated handshake deadline before entering their command + // and live-output loops. + stream.set_read_timeout(None)?; let client = host.next_client.fetch_add(1, Ordering::Relaxed); - // Queue admission is bounded by HostTap's byte counters. A fixed - // channel capacity would make harmless PTY fragmentation observable - // as a renderer disconnect. + // Queue admission is bounded by HostTap byte counters. let (sender, receiver) = mpsc_channel(); - let tap = HostTap::new(sender, Arc::new(stream.try_clone()?), MAX_HOST_CLIENT_QUEUED_BYTES); + let tap = HostTap::new( + sender, + Arc::new(cmux_tui_process::unix::clone_stream(&stream)?), + MAX_HOST_CLIENT_QUEUED_BYTES, + ); let command_sender = tap.clone(); let (snapshot, colors, snapshot_sequence, replay_gap, _active_client_stream) = { // Wait for a parser boundary without blocking resize or cell-metric @@ -5340,7 +6687,7 @@ mod unix { write_frame(&mut stream, &ready)?; } - let mut command_stream = stream.try_clone()?; + let mut command_stream = cmux_tui_process::unix::clone_stream(&stream)?; let command_host = host.clone(); thread::Builder::new().name("terminal-host-client-input".into()).spawn(move || { let mut detached = false; @@ -5583,6 +6930,10 @@ mod unix { break; } } + // This writer loop is the final owner of the connection. Closing the + // socket here also wakes the cloned input reader when a write timeout + // ends the loop before byte-queue overflow can close the tap. + tap.close(); host.remove_client(client); Ok(()) } @@ -6025,93 +7376,576 @@ mod unix { Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap())) } - fn u8(&mut self) -> anyhow::Result { - Ok(self.take(1)?[0]) - } + fn u8(&mut self) -> anyhow::Result { + Ok(self.take(1)?[0]) + } + + fn rgb(&mut self) -> anyhow::Result { + let bytes = self.take(3)?; + Ok(Rgb { r: bytes[0], g: bytes[1], b: bytes[2] }) + } + + fn u32(&mut self) -> anyhow::Result { + Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) + } + + fn u64(&mut self) -> anyhow::Result { + Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap())) + } + + fn bytes_with_limit(&mut self, limit: usize) -> anyhow::Result<&'a [u8]> { + let length = self.u32()? as usize; + if length > limit { + anyhow::bail!("terminal-host payload field is too large"); + } + self.take(length) + } + + fn blob(&mut self) -> anyhow::Result<&'a [u8]> { + self.bytes_with_limit(MAX_BLOB) + } + + fn string(&mut self) -> anyhow::Result { + Ok(std::str::from_utf8(self.bytes_with_limit(MAX_STRING)?)?.to_string()) + } + + fn optional_string(&mut self) -> anyhow::Result> { + match self.take(1)?[0] { + 0 => Ok(None), + 1 => Ok(Some(self.string()?)), + _ => anyhow::bail!("bad terminal-host optional string tag"), + } + } + + fn finish(&self) -> anyhow::Result<()> { + if self.offset != self.payload.len() { + anyhow::bail!("trailing terminal-host payload bytes"); + } + Ok(()) + } + } + + fn put_bytes(output: &mut Vec, bytes: &[u8]) -> anyhow::Result<()> { + if bytes.len() > MAX_STRING { + anyhow::bail!("terminal-host payload field is too large"); + } + output.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + output.extend_from_slice(bytes); + Ok(()) + } + + fn put_string(output: &mut Vec, value: &str) -> anyhow::Result<()> { + put_bytes(output, value.as_bytes()) + } + + fn put_blob(output: &mut Vec, value: &[u8]) -> anyhow::Result<()> { + if value.len() > MAX_BLOB { + anyhow::bail!("terminal-host payload blob is too large"); + } + output.extend_from_slice(&(value.len() as u32).to_le_bytes()); + output.extend_from_slice(value); + Ok(()) + } + + fn put_optional_string(output: &mut Vec, value: Option<&str>) -> anyhow::Result<()> { + match value { + Some(value) => { + output.push(1); + put_string(output, value) + } + None => { + output.push(0); + Ok(()) + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + fn spawn_sleeping_host_for_test(name: &str) -> (Arc, PathBuf) { + let root = std::env::temp_dir() + .join(format!("cmux-{name}-{}", crate::workspace_registry::new_uuid_v4())); + fs::create_dir_all(&root).unwrap(); + let bootstrap = HostBootstrap { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + terminal_id: TerminalId::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + }; + let mut bootstrap_bytes = Vec::new(); + write_frame(&mut bootstrap_bytes, &bootstrap.into_frame(1)).unwrap(); + let bootstrapped = crate::terminal_host::bootstrap_stdio_once( + &mut bootstrap_bytes.as_slice(), + &mut Vec::new(), + ) + .unwrap(); + let launch = HostLaunch { + endpoint: root.join("host.sock").to_string_lossy().into_owned(), + record_path: root.join("host.json").to_string_lossy().into_owned(), + term: "xterm-256color".into(), + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + scrollback: 100, + cwd: Some("/tmp".into()), + command: vec!["/bin/sh".into(), "-c".into(), "exec /bin/sleep 60".into()], + extra_env: Vec::new(), + default_colors: DefaultColors::default(), + kitty_graphics_limits: KittyGraphicsLimits::default(), + }; + (spawn_host_runtime(&launch, &bootstrapped).unwrap(), root) + } + + fn stop_test_host(host: &Arc, root: &Path) { + host.request_termination(); + assert!( + host.wait_for_child_exit(Duration::from_secs(3)), + "test terminal host did not stop" + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn unauthenticated_host_client_handshake_is_time_bounded() { + let _guard = HOST_REAP_TEST_LOCK.lock().unwrap(); + let (host, root) = spawn_sleeping_host_for_test("host-handshake-timeout"); + let (server, client) = cmux_tui_process::unix::pair_stream().unwrap(); + let client_host = host.clone(); + let (done_sender, done_receiver) = sync_channel(1); + let worker = thread::spawn(move || { + done_sender.send(serve_client(client_host, server)).unwrap(); + }); + + let result = + done_receiver.recv_timeout(HOST_HANDSHAKE_TIMEOUT + Duration::from_secs(1)); + drop(client); + if result.is_err() { + let _ = done_receiver.recv_timeout(Duration::from_secs(1)); + } + worker.join().unwrap(); + stop_test_host(&host, &root); + + match result { + Ok(Err(_)) => {} + Ok(Ok(())) => panic!("an empty client completed a terminal-host handshake"), + Err(RecvTimeoutError::Timeout) => { + panic!("an unauthenticated client retained a host thread past the deadline") + } + Err(RecvTimeoutError::Disconnected) => { + panic!("the terminal-host client worker disconnected without a result") + } + } + } + + #[test] + fn host_client_admission_is_bounded_and_reusable() { + let active = Arc::new(AtomicUsize::new(0)); + let mut admissions = (0..MAX_TERMINAL_HOST_CLIENTS) + .map(|_| { + HostClientAdmission::try_acquire(&active) + .expect("a client below the configured limit was rejected") + }) + .collect::>(); + + assert!( + HostClientAdmission::try_acquire(&active).is_none(), + "the terminal host admitted more than its configured client limit" + ); + admissions.pop(); + let replacement = HostClientAdmission::try_acquire(&active) + .expect("a released client admission was not reusable"); + drop(replacement); + drop(admissions); + + assert_eq!(active.load(Ordering::Acquire), 0); + } + + #[test] + fn idle_host_service_wait_is_event_driven() { + let root = std::env::temp_dir().join(format!( + "cmux-hsw-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).unwrap(); + let endpoint = root.join("host.sock"); + let listener = UnixListener::bind(&endpoint).unwrap(); + listener.set_nonblocking(true).unwrap(); + let (mut service_waker, mut service_waiter) = + cmux_tui_process::unix::pair_stream().unwrap(); + let dead = Arc::new(AtomicBool::new(false)); + let waiter_dead = dead.clone(); + let listener_fd = listener.as_raw_fd(); + let (done_sender, done_receiver) = sync_channel(1); + let worker = thread::spawn(move || { + done_sender + .send(wait_for_host_service_activity( + listener_fd, + &mut service_waiter, + &waiter_dead, + None, + )) + .unwrap(); + }); + + let first = done_receiver.recv_timeout(Duration::from_millis(75)); + dead.store(true, Ordering::Release); + let _ = service_waker.write_all(&[1]); + let (waited_for_event, result) = match first { + Ok(result) => (false, result), + Err(RecvTimeoutError::Timeout) => { + (true, done_receiver.recv_timeout(Duration::from_secs(1)).unwrap()) + } + Err(RecvTimeoutError::Disconnected) => { + panic!("the host service waiter disconnected without a result") + } + }; + worker.join().unwrap(); + drop(listener); + let _ = fs::remove_file(endpoint); + let _ = fs::remove_dir(root); + + assert!(waited_for_event, "an idle host service woke without socket activity"); + assert!(!result.unwrap(), "the exit wake was reported as client activity"); + } + + #[test] + fn host_service_wait_reports_listener_activity() { + let root = std::env::temp_dir().join(format!( + "cmux-hsc-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).unwrap(); + let endpoint = root.join("host.sock"); + let listener = UnixListener::bind(&endpoint).unwrap(); + listener.set_nonblocking(true).unwrap(); + let (_service_waker, mut service_waiter) = + cmux_tui_process::unix::pair_stream().unwrap(); + let dead = AtomicBool::new(false); + let listener_fd = listener.as_raw_fd(); + let worker = thread::spawn(move || { + wait_for_host_service_activity(listener_fd, &mut service_waiter, &dead, None) + }); + + let client = UnixStream::connect(&endpoint).unwrap(); + let activity = worker.join().unwrap().unwrap(); + drop(client); + drop(listener); + let _ = fs::remove_file(endpoint); + let _ = fs::remove_dir(root); + + assert!(activity, "a waiting host service ignored a client connection"); + } + + #[cfg(target_os = "macos")] + #[test] + fn host_pty_creation_waits_for_process_barrier() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_HOST_PTY_CREATION_BARRIER"; + const TEST_NAME: &str = + "terminal_host_runtime::unix::tests::host_pty_creation_waits_for_process_barrier"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "host PTY barrier subprocess failed: {status}"); + return; + } + + fn descriptor_count() -> usize { + fs::read_dir("/dev/fd").unwrap().count() + } + + drop(crate::process_session::reserve_child_reaper().unwrap()); + let bootstrap = HostBootstrap { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + terminal_id: TerminalId::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + }; + let mut bootstrap_bytes = Vec::new(); + write_frame(&mut bootstrap_bytes, &bootstrap.into_frame(1)).unwrap(); + let bootstrapped = crate::terminal_host::bootstrap_stdio_once( + &mut bootstrap_bytes.as_slice(), + &mut Vec::new(), + ) + .unwrap(); + let launch = HostLaunch { + endpoint: "/tmp/cmux-host-pty-barrier.sock".into(), + record_path: "/tmp/cmux-host-pty-barrier.json".into(), + term: "xterm-256color".into(), + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + scrollback: 100, + cwd: Some("/tmp".into()), + command: vec!["/bin/sleep".into(), "60".into()], + extra_env: Vec::new(), + default_colors: DefaultColors::default(), + kitty_graphics_limits: KittyGraphicsLimits::default(), + }; + let baseline = descriptor_count(); + let process_barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (started_sender, started_receiver) = sync_channel(1); + let worker = thread::spawn(move || { + started_sender.send(()).unwrap(); + spawn_host_runtime(&launch, &bootstrapped) + }); + started_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + let deadline = Instant::now() + Duration::from_millis(250); + let mut observed = baseline; + while observed == baseline && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + observed = descriptor_count(); + } + + drop(process_barrier); + let host = worker.join().unwrap().unwrap(); + host.request_termination(); + let _ = host.wait_for_child_exit(Duration::from_secs(1)); - fn rgb(&mut self) -> anyhow::Result { - let bytes = self.take(3)?; - Ok(Rgb { r: bytes[0], g: bytes[1], b: bytes[2] }) + assert_eq!( + observed, baseline, + "host PTY descriptors were created outside the process barrier" + ); } - fn u32(&mut self) -> anyhow::Result { - Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) - } + #[test] + fn host_pty_does_not_inherit_launcher_command() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_HOST_LAUNCHER_ENV"; + const LAUNCHER_ENV: &str = "CMUX_TUI_LAUNCHER_COMMAND"; + const TEST_NAME: &str = + "terminal_host_runtime::unix::tests::host_pty_does_not_inherit_launcher_command"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .env(LAUNCHER_ENV, "npx cmux@1.2.3") + .status() + .unwrap(); + assert!(status.success(), "host PTY environment subprocess failed: {status}"); + return; + } - fn u64(&mut self) -> anyhow::Result { - Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap())) - } + let root = std::env::temp_dir().join(format!( + "cmux-host-launcher-env-{}", + crate::workspace_registry::new_uuid_v4() + )); + fs::create_dir_all(&root).unwrap(); + let result = root.join("launcher.txt"); + let bootstrap = HostBootstrap { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + terminal_id: TerminalId::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + }; + let mut bootstrap_bytes = Vec::new(); + write_frame(&mut bootstrap_bytes, &bootstrap.into_frame(1)).unwrap(); + let bootstrapped = crate::terminal_host::bootstrap_stdio_once( + &mut bootstrap_bytes.as_slice(), + &mut Vec::new(), + ) + .unwrap(); + let launch = HostLaunch { + endpoint: root.join("host.sock").to_string_lossy().into_owned(), + record_path: root.join("host.json").to_string_lossy().into_owned(), + term: "xterm-256color".into(), + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + scrollback: 100, + cwd: Some("/tmp".into()), + command: vec![ + "/bin/sh".into(), + "-c".into(), + "result=\"$CMUX_TUI_TEST_RESULT\"; \ + printf '%s' \"${CMUX_TUI_LAUNCHER_COMMAND-unset}\" > \"$result.tmp\"; \ + mv \"$result.tmp\" \"$result\"; exec /bin/sleep 60" + .into(), + ], + extra_env: vec![( + "CMUX_TUI_TEST_RESULT".into(), + result.to_string_lossy().into_owned(), + )], + default_colors: DefaultColors::default(), + kitty_graphics_limits: KittyGraphicsLimits::default(), + }; - fn bytes_with_limit(&mut self, limit: usize) -> anyhow::Result<&'a [u8]> { - let length = self.u32()? as usize; - if length > limit { - anyhow::bail!("terminal-host payload field is too large"); + let host = spawn_host_runtime(&launch, &bootstrapped).unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + while !result.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); } - self.take(length) - } + let inherited = fs::read_to_string(&result).unwrap(); + host.request_termination(); + let _ = host.wait_for_child_exit(Duration::from_secs(1)); + let _ = fs::remove_dir_all(root); - fn blob(&mut self) -> anyhow::Result<&'a [u8]> { - self.bytes_with_limit(MAX_BLOB) + assert_eq!(inherited, "unset", "host PTY inherited launcher replay metadata"); } - fn string(&mut self) -> anyhow::Result { - Ok(std::str::from_utf8(self.bytes_with_limit(MAX_STRING)?)?.to_string()) - } + #[test] + fn failed_host_pty_initialization_reaps_spawned_child() { + let root = std::env::temp_dir().join(format!( + "cmux-host-spawn-failure-{}", + crate::workspace_registry::new_uuid_v4() + )); + fs::create_dir_all(&root).unwrap(); + let ready = root.join("ready"); + let descendant = root.join("descendant"); + let signal = root.join("spawn-ready"); + let _failure = + crate::process_session::force_post_spawn_failure_for_test(signal.clone()); + let bootstrap = HostBootstrap { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + terminal_id: TerminalId::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + }; + let mut bootstrap_bytes = Vec::new(); + write_frame(&mut bootstrap_bytes, &bootstrap.into_frame(1)).unwrap(); + let bootstrapped = crate::terminal_host::bootstrap_stdio_once( + &mut bootstrap_bytes.as_slice(), + &mut Vec::new(), + ) + .unwrap(); + let launch = HostLaunch { + endpoint: root.join("host.sock").to_string_lossy().into_owned(), + record_path: root.join("host.json").to_string_lossy().into_owned(), + term: "xterm-256color".into(), + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + scrollback: 100, + cwd: Some("/tmp".into()), + command: vec![ + "/bin/sh".into(), + "-c".into(), + format!( + "trap '' HUP TERM; \ + (trap '' HUP TERM; exec /bin/sleep 60) & \ + echo $! > {}; \ + echo $$ > {}; \ + printf ready > {}; exec /bin/sleep 60", + descendant.display(), + ready.display(), + signal.display() + ), + ], + extra_env: Vec::new(), + default_colors: DefaultColors::default(), + kitty_graphics_limits: KittyGraphicsLimits::default(), + }; - fn optional_string(&mut self) -> anyhow::Result> { - match self.take(1)?[0] { - 0 => Ok(None), - 1 => Ok(Some(self.string()?)), - _ => anyhow::bail!("bad terminal-host optional string tag"), + let error = match spawn_host_runtime(&launch, &bootstrapped) { + Ok(_) => panic!("forced hosted PTY initialization failure unexpectedly succeeded"), + Err(error) => error, + }; + assert!(error.to_string().contains("forced post-spawn PTY initialization failure")); + let pid = fs::read_to_string(&ready).unwrap().trim().parse::().unwrap(); + let descendant_pid = + fs::read_to_string(&descendant).unwrap().trim().parse::().unwrap(); + let mut status = 0; + // SAFETY: the marker contains the exact child PID spawned by this test. + let result = unsafe { libc::waitpid(pid, &raw mut status, libc::WNOHANG) }; + let wait_error = std::io::Error::last_os_error(); + if result == 0 { + // SAFETY: the child is still owned by this test process. + unsafe { + libc::kill(pid, libc::SIGKILL); + libc::waitpid(pid, &raw mut status, 0); + } } - } - - fn finish(&self) -> anyhow::Result<()> { - if self.offset != self.payload.len() { - anyhow::bail!("trailing terminal-host payload bytes"); + let descendant_deadline = Instant::now() + Duration::from_secs(2); + let descendant_gone = loop { + // SAFETY: signal zero performs a non-mutating liveness probe. + if unsafe { libc::kill(descendant_pid, 0) } < 0 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + { + break true; + } + if Instant::now() >= descendant_deadline { + break false; + } + thread::sleep(Duration::from_millis(10)); + }; + if !descendant_gone { + // SAFETY: the marker names the test's retained-session descendant. + unsafe { + libc::kill(descendant_pid, libc::SIGKILL); + } } - Ok(()) - } - } - - fn put_bytes(output: &mut Vec, bytes: &[u8]) -> anyhow::Result<()> { - if bytes.len() > MAX_STRING { - anyhow::bail!("terminal-host payload field is too large"); - } - output.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); - output.extend_from_slice(bytes); - Ok(()) - } - - fn put_string(output: &mut Vec, value: &str) -> anyhow::Result<()> { - put_bytes(output, value.as_bytes()) - } + let _ = fs::remove_dir_all(root); - fn put_blob(output: &mut Vec, value: &[u8]) -> anyhow::Result<()> { - if value.len() > MAX_BLOB { - anyhow::bail!("terminal-host payload blob is too large"); + assert_eq!(result, -1, "failed hosted PTY initialization left child {pid} unreaped"); + assert_eq!(wait_error.raw_os_error(), Some(libc::ECHILD)); + assert!( + descendant_gone, + "failed hosted PTY initialization left descendant {descendant_pid} running" + ); } - output.extend_from_slice(&(value.len() as u32).to_le_bytes()); - output.extend_from_slice(value); - Ok(()) - } - fn put_optional_string(output: &mut Vec, value: Option<&str>) -> anyhow::Result<()> { - match value { - Some(value) => { - output.push(1); - put_string(output, value) - } - None => { - output.push(0); - Ok(()) + #[cfg(target_os = "linux")] + #[test] + fn saturated_listener_never_returns_an_unconnected_host_stream() { + let path = std::env::temp_dir().join(format!( + "cmux-host-backlog-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let (address, address_len) = unix_socket_address(&path).unwrap(); + // SAFETY: socket has no pointer arguments and returns a new owned + // descriptor on success. + let listener = + unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) }; + assert!(listener >= 0, "failed to create the test listener"); + // SAFETY: listener is a fresh successful socket result and this + // OwnedFd takes its sole ownership. + let listener = unsafe { OwnedFd::from_raw_fd(listener) }; + // SAFETY: address is initialized for this exact filesystem path + // and listener owns a valid AF_UNIX descriptor. + let result = unsafe { + libc::bind( + listener.as_raw_fd(), + (&raw const address).cast::(), + address_len, + ) + }; + assert_eq!(result, 0, "failed to bind the test listener"); + // SAFETY: listener remains a valid bound AF_UNIX descriptor. + let result = unsafe { libc::listen(listener.as_raw_fd(), 0) }; + assert_eq!(result, 0, "failed to create a zero-backlog test listener"); + + let mut queued = Vec::new(); + let mut timeout_elapsed = None; + for _ in 0..32 { + let started = Instant::now(); + match connect_until(&path, started + Duration::from_millis(75)) { + Ok(stream) => queued.push(stream), + Err(error) => { + assert_eq!( + error.downcast_ref::().map(std::io::Error::kind), + Some(std::io::ErrorKind::TimedOut) + ); + timeout_elapsed = Some(started.elapsed()); + break; + } + } } + let elapsed = + timeout_elapsed.expect("saturated host listener returned unconnected streams"); + assert!( + elapsed < Duration::from_secs(1), + "saturated host connect exceeded its deadline bound: {elapsed:?}" + ); + drop(queued); + drop(listener); + fs::remove_file(path).unwrap(); } - } - - #[cfg(test)] - mod tests { - use super::*; fn test_kitty_state() -> KittyReplayState { KittyReplayState { @@ -6182,6 +8016,7 @@ mod unix { term.resize(80, 24, u32::from(DEFAULT_CELL_PIXELS.0), u32::from(DEFAULT_CELL_PIXELS.1)) .unwrap(); let (pty_drain_waker, _pty_drain_waiter) = UnixStream::pair().unwrap(); + let (service_exit_waker, service_exit_waiter) = UnixStream::pair().unwrap(); let (exit_publish_requests, exit_publish_receiver) = mpsc_channel(); let (parser_commands, parser_receiver) = sync_channel(1); let terminal_id = TerminalId::random().unwrap(); @@ -6207,6 +8042,7 @@ mod unix { cell_pixels: Mutex::new(DEFAULT_CELL_PIXELS), viewer_sizes: Mutex::new(HashMap::new()), taps: Mutex::new(HashMap::new()), + taps_changed: Condvar::new(), broadcast_lock: Mutex::new(()), sequence: AtomicU64::new(0), smart: SmartStreamState::new(), @@ -6215,6 +8051,7 @@ mod unix { parser_budget: ParserBudget::new(1), parser_progress: (Mutex::new(0), Condvar::new()), next_client: AtomicU64::new(1), + active_clients: Arc::new(AtomicUsize::new(0)), dead: AtomicBool::new(false), launch_owner_claimed: AtomicBool::new(true), launch_owner_stream_ready: AtomicBool::new(true), @@ -6236,10 +8073,15 @@ mod unix { exit_publish_requests, force_pty_drain: AtomicBool::new(false), pty_drain_waker: Mutex::new(pty_drain_waker), + service_exit_waker: Mutex::new(service_exit_waker), + service_exit_waiter: Mutex::new(Some(service_exit_waiter)), termination_started: AtomicBool::new(false), child_signal_lock: Mutex::new(()), child_reaped: AtomicBool::new(true), + child_wait_ownership_lost: AtomicBool::new(false), group_escalation_complete: AtomicBool::new(false), + normal_cleanup_failures: AtomicUsize::new(0), + normal_cleanup_attempts: AtomicUsize::new(0), fail_next_resize_publication: AtomicBool::new(false), }); HostShared::start_exit_publisher(&host, exit_publish_receiver).unwrap(); @@ -6275,6 +8117,7 @@ mod unix { term.resize(80, 24, u32::from(DEFAULT_CELL_PIXELS.0), u32::from(DEFAULT_CELL_PIXELS.1)) .unwrap(); let (pty_drain_waker, _pty_drain_waiter) = UnixStream::pair().unwrap(); + let (service_exit_waker, service_exit_waiter) = UnixStream::pair().unwrap(); let (exit_publish_requests, exit_publish_receiver) = mpsc_channel(); let (parser_commands, _parser_receiver) = sync_channel(1); let host = Arc::new(HostShared { @@ -6297,6 +8140,7 @@ mod unix { cell_pixels: Mutex::new(DEFAULT_CELL_PIXELS), viewer_sizes: Mutex::new(HashMap::new()), taps: Mutex::new(HashMap::new()), + taps_changed: Condvar::new(), broadcast_lock: Mutex::new(()), sequence: AtomicU64::new(0), smart: SmartStreamState::new(), @@ -6305,6 +8149,7 @@ mod unix { parser_budget: ParserBudget::new(1), parser_progress: (Mutex::new(0), Condvar::new()), next_client: AtomicU64::new(1), + active_clients: Arc::new(AtomicUsize::new(0)), dead: AtomicBool::new(false), launch_owner_claimed: AtomicBool::new(false), launch_owner_stream_ready: AtomicBool::new(false), @@ -6322,10 +8167,15 @@ mod unix { exit_publish_requests, force_pty_drain: AtomicBool::new(false), pty_drain_waker: Mutex::new(pty_drain_waker), + service_exit_waker: Mutex::new(service_exit_waker), + service_exit_waiter: Mutex::new(Some(service_exit_waiter)), termination_started: AtomicBool::new(false), child_signal_lock: Mutex::new(()), child_reaped: AtomicBool::new(false), + child_wait_ownership_lost: AtomicBool::new(false), group_escalation_complete: AtomicBool::new(false), + normal_cleanup_failures: AtomicUsize::new(0), + normal_cleanup_attempts: AtomicUsize::new(0), fail_next_resize_publication: AtomicBool::new(false), }); HostShared::start_exit_publisher(&host, exit_publish_receiver).unwrap(); @@ -6355,6 +8205,7 @@ mod unix { host_start_nonce: encode_hex(nonce.as_bytes()), workspace_key: String::new(), supports_set_defaults: true, + supports_terminate_only: true, supports_clear_history: true, supports_terminate_ack: true, }; @@ -6364,6 +8215,24 @@ mod unix { (record_path, record, lease) } + fn accept_test_client_until( + listener: &UnixListener, + timeout: Duration, + ) -> Option { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + timeout; + loop { + match listener.accept() { + Ok((stream, _)) => return Some(stream), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + let remaining = deadline.checked_duration_since(Instant::now())?; + thread::sleep(remaining.min(Duration::from_millis(1))); + } + Err(error) => panic!("test listener failed: {error}"), + } + } + } + #[test] fn default_host_cell_metrics_initialize_both_terminal_backends() { let size = pty_size(80, 24, DEFAULT_CELL_PIXELS).unwrap(); @@ -6970,6 +8839,46 @@ mod unix { let _ = fs::remove_dir_all(record_path.parent().unwrap()); } + #[test] + fn liveness_lease_drop_unlocks_an_inherited_file_description() { + let (record_path, record, lease) = record_fixture("inherited-liveness"); + let lease_fd = lease.file.as_raw_fd(); + let mut command = Command::new("/bin/sleep"); + command.arg("60"); + // SAFETY: fcntl is async-signal-safe, and this only clears + // close-on-exec on the test-owned descriptor in the child between + // fork and exec. The parent descriptor remains close-on-exec. + unsafe { + command.pre_exec(move || { + let flags = libc::fcntl(lease_fd, libc::F_GETFD); + if flags == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::fcntl(lease_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut inheritor = command.spawn().unwrap(); + + drop(lease); + let observed = terminal_host_record_liveness(&record_path, &record).unwrap(); + + inheritor.kill().unwrap(); + inheritor.wait().unwrap(); + assert!( + remove_stale_terminal_host_record(&record_path, &record).unwrap(), + "test cleanup could not reclaim the released liveness record" + ); + let _ = fs::remove_dir_all(record_path.parent().unwrap()); + assert_eq!( + observed, + TerminalHostLiveness::Dead, + "a forked child retained the terminal host's released liveness lock" + ); + } + #[test] fn launch_publication_reservation_blocks_reset_lock_until_released() { let root = std::env::temp_dir().join(format!( @@ -7062,11 +8971,162 @@ mod unix { ) .unwrap(); - let loaded = load_terminal_host_records(root).unwrap(); - assert_eq!(loaded, vec![(record_path.clone(), record.clone())]); - drop(lease); - assert!(remove_stale_terminal_host_record(&record_path, &record).unwrap()); - let _ = fs::remove_dir_all(root); + let loaded = load_terminal_host_records(root).unwrap(); + assert_eq!(loaded, vec![(record_path.clone(), record.clone())]); + drop(lease); + assert!(remove_stale_terminal_host_record(&record_path, &record).unwrap()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn exact_record_loader_does_not_read_unrelated_host_files() { + let (record_path, record, lease) = record_fixture("exact-record"); + let root = record_path.parent().unwrap(); + fs::write(root.join("unrelated.json"), b"{invalid-json").unwrap(); + + let loaded = load_terminal_host_record(root, &record.terminal_id).unwrap(); + + assert_eq!(loaded, Some((record_path.clone(), record.clone()))); + drop(lease); + assert!(remove_stale_terminal_host_record(&record_path, &record).unwrap()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn private_directory_setup_rejects_symlink_without_changing_target_permissions() { + let root = std::env::temp_dir().join(format!( + "cmux-host-private-dir-symlink-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).unwrap(); + let target = root.join("target"); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + let link = root.join("endpoint"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let result = prepare_private_dir(&link); + let target_mode = fs::metadata(&target).unwrap().mode() & 0o777; + let _ = fs::remove_dir_all(&root); + + assert!(result.is_err(), "private directory setup accepted a symlink"); + assert_eq!(target_mode, 0o755, "private directory setup changed the symlink target"); + } + + #[test] + fn private_directory_setup_tightens_an_owned_directory() { + let root = std::env::temp_dir().join(format!( + "cmux-host-private-dir-owned-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + + prepare_private_dir(&root).unwrap(); + let mode = fs::metadata(&root).unwrap().mode() & 0o777; + let _ = fs::remove_dir_all(&root); + + assert_eq!(mode, 0o700); + } + + #[test] + fn strict_record_loader_rejects_fifo_without_blocking() { + let root = std::env::temp_dir().join(format!( + "cmux-host-record-fifo-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + prepare_private_dir(&root).unwrap(); + let path = root.join(format!("{}.json", TerminalId::random().unwrap().to_hex())); + let c_path = CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: c_path is a valid, NUL-terminated path owned by this test. + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0); + + let loader_root = root.clone(); + let (result_tx, result_rx) = sync_channel(1); + let loader = thread::spawn(move || { + let _ = result_tx.send(load_terminal_host_records_strict( + &loader_root, + 1, + Instant::now() + Duration::from_secs(1), + )); + }); + let completed = match result_rx.recv_timeout(Duration::from_millis(500)) { + Ok(result) => Some(result), + Err(RecvTimeoutError::Timeout) => { + let mut writer = OpenOptions::new().write(true).open(&path).unwrap(); + writer.write_all(b"{}").unwrap(); + None + } + Err(RecvTimeoutError::Disconnected) => panic!("record loader stopped"), + }; + loader.join().unwrap(); + let _ = fs::remove_dir_all(&root); + + assert!(completed.is_some(), "strict terminal-host scan blocked while opening a FIFO"); + assert!(completed.unwrap().is_err(), "FIFO was accepted as a discovery record"); + } + + #[test] + fn adoption_record_loader_rejects_fifo_without_blocking() { + let root = std::env::temp_dir().join(format!( + "cmux-host-adoption-record-fifo-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + prepare_private_dir(&root).unwrap(); + let path = root.join(format!("{}.json", TerminalId::random().unwrap().to_hex())); + let c_path = CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: c_path is a valid, NUL-terminated path owned by this test. + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0); + + let loader_root = root.clone(); + let (result_tx, result_rx) = sync_channel(1); + let loader = thread::spawn(move || { + let _ = result_tx.send(load_terminal_host_records(&loader_root)); + }); + let completed = match result_rx.recv_timeout(Duration::from_millis(500)) { + Ok(result) => Some(result), + Err(RecvTimeoutError::Timeout) => { + let mut writer = OpenOptions::new().write(true).open(&path).unwrap(); + writer.write_all(b"{}").unwrap(); + None + } + Err(RecvTimeoutError::Disconnected) => panic!("record loader stopped"), + }; + loader.join().unwrap(); + let _ = fs::remove_dir_all(&root); + + assert!(completed.is_some(), "terminal-host adoption blocked while opening a FIFO"); + assert!(completed.unwrap().unwrap().is_empty(), "FIFO was adopted as a host record"); + } + + #[test] + fn strict_record_loader_rejects_oversized_file_before_decoding() { + let root = std::env::temp_dir().join(format!( + "cmux-host-record-oversized-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + prepare_private_dir(&root).unwrap(); + let path = root.join(format!("{}.json", TerminalId::random().unwrap().to_hex())); + fs::write(&path, vec![b'x'; MAX_LAUNCH_PAYLOAD + 1]).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let error = load_terminal_host_records_strict( + &root, + 1, + Instant::now() + Duration::from_secs(1), + ) + .unwrap_err(); + let _ = fs::remove_dir_all(&root); + + assert!( + format!("{error:#}").contains("exceeds size limit"), + "oversized record reached JSON decoding: {error:#}" + ); } #[test] @@ -7110,6 +9170,35 @@ mod unix { let _ = fs::remove_dir_all(root); } + #[test] + fn confirmed_host_termination_acknowledges_its_exact_exit_sidecar() { + let (record_path, record, lease) = record_fixture("confirmed-exit-sidecar"); + let root = record_path.parent().unwrap().to_path_buf(); + let exit_record = TerminalHostExitRecord::new( + &TerminalHostIdentity { + terminal_id: record.terminal_id.clone(), + incarnation: record.incarnation.clone(), + }, + TerminalExit { + outcome: crate::terminal_host_protocol::TerminalExitOutcome::Exit { code: 17 }, + exited_at_ms: 1_234_567, + }, + ); + let exit_path = record_path.with_extension("exit"); + write_exit_record(&exit_path, &exit_record).unwrap(); + drop(lease); + + assert!(terminate_and_confirm_terminal_host_record( + &record, + &record_path, + Instant::now() + Duration::from_secs(1), + )); + assert!(!record_path.exists(), "confirmed termination retained its discovery record"); + assert!(!exit_path.exists(), "confirmed termination retained its exact exit sidecar"); + + let _ = fs::remove_dir_all(root); + } + #[test] fn exit_sidecar_publication_never_clobbers_a_concurrent_outcome() { let (record_path, record, lease) = record_fixture("exit-sidecar-race"); @@ -7185,6 +9274,7 @@ mod unix { legacy.host_pid = 0; legacy.host_start_nonce.clear(); legacy.supports_set_defaults = false; + legacy.supports_terminate_only = false; legacy.supports_clear_history = false; legacy.supports_terminate_ack = false; let legacy_path = legacy.record_path(root); @@ -7228,6 +9318,327 @@ mod unix { assert_eq!(*viewers.lock().unwrap(), HashMap::from([(1, (80, 24))])); } + #[test] + fn hosted_child_exit_retries_a_failed_session_cleanup() { + let _guard = HOST_REAP_TEST_LOCK.lock().unwrap(); + NEXT_HOST_NORMAL_CLEANUP_FAILURES.store(1, Ordering::Release); + let bootstrap = HostBootstrap { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + terminal_id: TerminalId::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + }; + let mut bootstrap_bytes = Vec::new(); + write_frame(&mut bootstrap_bytes, &bootstrap.into_frame(1)).unwrap(); + let bootstrapped = crate::terminal_host::bootstrap_stdio_once( + &mut bootstrap_bytes.as_slice(), + &mut Vec::new(), + ) + .unwrap(); + let launch = HostLaunch { + endpoint: "/tmp/cmux-host-reap-retry.sock".into(), + record_path: "/tmp/cmux-host-reap-retry.json".into(), + term: "xterm-256color".into(), + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + scrollback: 100, + cwd: Some("/tmp".into()), + command: vec!["/bin/sh".into(), "-c".into(), "exit 0".into()], + extra_env: Vec::new(), + default_colors: DefaultColors::default(), + kitty_graphics_limits: KittyGraphicsLimits::default(), + }; + let host = spawn_host_runtime(&launch, &bootstrapped).unwrap(); + + let retry_deadline = Instant::now() + Duration::from_secs(3); + while (!host.child_reaped.load(Ordering::Acquire) + || host.normal_cleanup_attempts.load(Ordering::Acquire) < 2) + && Instant::now() < retry_deadline + { + thread::sleep(Duration::from_millis(10)); + } + let retried = host.normal_cleanup_attempts.load(Ordering::Acquire) >= 2; + let reaped = host.child_reaped.load(Ordering::Acquire); + host.request_termination(); + let _ = host.wait_for_child_exit(Duration::from_secs(1)); + + assert!( + retried, + "hosted PTY cleanup was not retried after a transient failure (attempts={})", + host.normal_cleanup_attempts.load(Ordering::Acquire) + ); + assert!(reaped, "the hosted PTY leader remained unreaped after cleanup could succeed"); + } + + #[test] + fn hosted_session_kill_observes_the_waitable_leader_under_its_owner_lock() { + let _guard = HOST_REAP_TEST_LOCK.lock().unwrap(); + let root = std::env::temp_dir() + .join(format!("cmux-host-owner-{}", crate::workspace_registry::new_uuid_v4())); + fs::create_dir_all(&root).unwrap(); + let bootstrap = HostBootstrap { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + terminal_id: TerminalId::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + }; + let mut bootstrap_bytes = Vec::new(); + write_frame(&mut bootstrap_bytes, &bootstrap.into_frame(1)).unwrap(); + let bootstrapped = crate::terminal_host::bootstrap_stdio_once( + &mut bootstrap_bytes.as_slice(), + &mut Vec::new(), + ) + .unwrap(); + let launch = HostLaunch { + endpoint: root.join("host.sock").to_string_lossy().into_owned(), + record_path: root.join("host.json").to_string_lossy().into_owned(), + term: "xterm-256color".into(), + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + scrollback: 100, + cwd: Some("/tmp".into()), + command: vec![ + "/bin/sh".into(), + "-c".into(), + "trap '' HUP TERM; while :; do sleep 60; done".into(), + ], + extra_env: Vec::new(), + default_colors: DefaultColors::default(), + kitty_graphics_limits: KittyGraphicsLimits::default(), + }; + let host = spawn_host_runtime(&launch, &bootstrapped).unwrap(); + host.termination_started.store(true, Ordering::Release); + + let started = Instant::now(); + let killed = host + .kill_terminal_process_session_until(Instant::now() + Duration::from_secs(1)) + .unwrap(); + let elapsed = started.elapsed(); + + host.group_escalation_complete.store(true, Ordering::Release); + host.child_exit.1.notify_all(); + host.wake_reserved_child_reaper(); + let _ = host.wait_for_child_exit(Duration::from_secs(2)); + let _ = fs::remove_dir_all(root); + + assert!(killed, "host cleanup could not observe its SIGKILLed leader"); + assert!( + elapsed < Duration::from_millis(500), + "host cleanup waited on the reaper while retaining its owner lock: {elapsed:?}" + ); + assert!(host.child_reaped.load(Ordering::Acquire)); + } + + #[test] + fn hosted_child_observation_uses_the_reserved_shared_reaper() { + const CHILDREN: usize = 4; + + let _guard = HOST_REAP_TEST_LOCK.lock().unwrap(); + let worker_baseline = crate::process_session::dedicated_natural_reap_workers_for_test(); + let observer_baseline = HOST_CHILD_OBSERVER_SPAWNS.load(Ordering::Acquire); + let mut hosts = Vec::new(); + for index in 0..CHILDREN { + let bootstrap = HostBootstrap { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + terminal_id: TerminalId::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + }; + let mut bootstrap_bytes = Vec::new(); + write_frame(&mut bootstrap_bytes, &bootstrap.into_frame(1)).unwrap(); + let bootstrapped = crate::terminal_host::bootstrap_stdio_once( + &mut bootstrap_bytes.as_slice(), + &mut Vec::new(), + ) + .unwrap(); + let launch = HostLaunch { + endpoint: format!("/tmp/cmux-host-reap-bound-{index}.sock"), + record_path: format!("/tmp/cmux-host-reap-bound-{index}.json"), + term: "xterm-256color".into(), + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + scrollback: 100, + cwd: Some("/tmp".into()), + command: vec!["/bin/sh".into(), "-c".into(), "exit 0".into()], + extra_env: Vec::new(), + default_colors: DefaultColors::default(), + kitty_graphics_limits: KittyGraphicsLimits::default(), + }; + hosts.push(spawn_host_runtime(&launch, &bootstrapped).unwrap()); + } + + for host in &hosts { + let deadline = Instant::now() + Duration::from_secs(3); + while !host.child_reaped.load(Ordering::Acquire) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!( + host.child_reaped.load(Ordering::Acquire), + "hosted PTY child was not reaped by the bounded shared worker" + ); + } + let dedicated_workers = + crate::process_session::dedicated_natural_reap_workers_for_test() + .saturating_sub(worker_baseline); + let dedicated_observers = HOST_CHILD_OBSERVER_SPAWNS + .load(Ordering::Acquire) + .saturating_sub(observer_baseline); + + for host in hosts { + host.request_termination(); + let _ = host.wait_for_child_exit(Duration::from_secs(1)); + } + + assert!( + dedicated_workers <= 1, + "hosted PTY observation retained {dedicated_workers} shared retry workers" + ); + assert_eq!( + dedicated_observers, 0, + "hosted PTY child observation retained one dedicated thread per child" + ); + } + + #[test] + fn hosted_child_exit_cleans_descendants_that_keep_the_slave_open() { + let _guard = HOST_REAP_TEST_LOCK.lock().unwrap(); + let root = std::env::temp_dir() + .join(format!("cmux-host-reap-{}", crate::workspace_registry::new_uuid_v4())); + fs::create_dir_all(&root).unwrap(); + let pid_path = root.join("descendant.pid"); + let ready_path = root.join("descendant.ready"); + let release_path = root.join("leader.release"); + let bootstrap = HostBootstrap { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + terminal_id: TerminalId::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + }; + let mut bootstrap_bytes = Vec::new(); + write_frame(&mut bootstrap_bytes, &bootstrap.into_frame(1)).unwrap(); + let bootstrapped = crate::terminal_host::bootstrap_stdio_once( + &mut bootstrap_bytes.as_slice(), + &mut Vec::new(), + ) + .unwrap(); + let launch = HostLaunch { + endpoint: root.join("host.sock").to_string_lossy().into_owned(), + record_path: root.join("host.json").to_string_lossy().into_owned(), + term: "xterm-256color".into(), + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + scrollback: 100, + cwd: Some("/tmp".into()), + command: vec![ + "/bin/sh".into(), + "-c".into(), + format!( + "trap '' HUP TERM; terminal=$(tty); \ + sleep 30 3<>\"$terminal\" & child=$!; \ + : > {ready}; echo $child > {pid}; \ + while [ ! -e {release} ]; do sleep 0.01; done; exit 0", + ready = ready_path.display(), + pid = pid_path.display(), + release = release_path.display(), + ), + ], + extra_env: Vec::new(), + default_colors: DefaultColors::default(), + kitty_graphics_limits: KittyGraphicsLimits::default(), + }; + let host = spawn_host_runtime(&launch, &bootstrapped).unwrap(); + let start_deadline = Instant::now() + Duration::from_secs(1); + while !pid_path.exists() && Instant::now() < start_deadline { + thread::sleep(Duration::from_millis(10)); + } + let descendant = + fs::read_to_string(&pid_path).unwrap().trim().parse::().unwrap(); + // SAFETY: signal 0 only probes the test-owned PID. + assert_eq!(unsafe { libc::kill(descendant, 0) }, 0); + fs::write(&release_path, b"ready").unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + while !host.child_reaped.load(Ordering::Acquire) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + let naturally_reaped = host.child_reaped.load(Ordering::Acquire); + // SAFETY: signal 0 only probes the test-owned PID. + let descendant_alive_before_cleanup = unsafe { libc::kill(descendant, 0) } == 0; + + host.request_termination(); + let _ = host.wait_for_child_exit(Duration::from_secs(1)); + if unsafe { libc::kill(descendant, 0) } == 0 { + // SAFETY: this PID was written by the test-owned descendant. + unsafe { + libc::kill(descendant, libc::SIGKILL); + } + } + let _ = fs::remove_dir_all(root); + + assert!( + naturally_reaped, + "hosted PTY exit waited for EOF from a same-session descendant before cleanup" + ); + assert!( + !descendant_alive_before_cleanup, + "hosted PTY exit left a same-session descendant holding the slave open" + ); + } + + #[test] + fn detached_host_process_retains_reap_ownership_when_worker_spawn_fails() { + let _guard = HOST_REAP_TEST_LOCK.lock().unwrap(); + let child = + Command::new("/bin/sh").arg("-c").arg("exit 0").spawn().expect("spawn test child"); + let pid = libc::pid_t::try_from(child.id()).expect("child PID fits pid_t"); + let (process, completed) = SpawnedHostProcess::new_for_test(child); + NEXT_HOST_PROCESS_REAPER_SPAWN_FAILURES.store(1, Ordering::Release); + + process.detach_reaper(); + + let reaped_by_owner = completed.recv_timeout(Duration::from_secs(1)).is_ok(); + NEXT_HOST_PROCESS_REAPER_SPAWN_FAILURES.store(0, Ordering::Release); + if !reaped_by_owner { + // SAFETY: pid remains the exact test-owned child while its + // reaper reservation is still outstanding. + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + + assert!( + reaped_by_owner, + "detached terminal-host cleanup abandoned the child after thread spawn failed" + ); + let mut status = 0; + // SAFETY: completion proves the reaper dropped its sole Child + // handle after observing exit, so this call only verifies ECHILD. + let result = unsafe { libc::waitpid(pid, &raw mut status, libc::WNOHANG) }; + assert_eq!(result, -1); + assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ECHILD)); + } + + #[test] + fn spawned_host_process_drop_transfers_waiting_to_the_shared_reaper() { + let _guard = HOST_REAP_TEST_LOCK.lock().unwrap(); + HOST_PROCESS_INLINE_WAITS.store(0, Ordering::Release); + let child = + Command::new("/bin/sh").arg("-c").arg("exit 0").spawn().expect("spawn test child"); + let (process, completed) = SpawnedHostProcess::new_for_test(child); + + drop(process); + + completed.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!( + HOST_PROCESS_INLINE_WAITS.load(Ordering::Acquire), + 0, + "dropping a launch guard waited for the child on the cancellation thread" + ); + } + #[test] fn stalled_host_handshake_is_time_bounded() { let (record_path, record, lease) = record_fixture("handshake-timeout"); @@ -7260,6 +9671,46 @@ mod unix { let _ = fs::remove_dir_all(record_path.parent().unwrap()); } + #[test] + fn terminate_only_handshake_uses_one_absolute_deadline() { + let (record_path, record, lease) = record_fixture("terminate-deadline"); + let endpoint = PathBuf::from(&record.endpoint); + prepare_private_dir(endpoint.parent().unwrap()).unwrap(); + let _ = fs::remove_file(&endpoint); + let listener = UnixListener::bind(&endpoint).unwrap(); + let stalled = thread::spawn(move || { + if let Some(mut stream) = + accept_test_client_until(&listener, Duration::from_millis(500)) + { + for _ in 0..crate::terminal_host_protocol::HEADER_LEN { + if stream.write_all(&[0]).is_err() { + break; + } + thread::sleep(Duration::from_millis(20)); + } + } + }); + + let started = Instant::now(); + let result = terminate_terminal_host_with_timeout( + &record, + &record_path, + Duration::from_millis(40), + ); + let elapsed = started.elapsed(); + stalled.join().unwrap(); + let _ = fs::remove_file(endpoint); + drop(lease); + assert!(remove_stale_terminal_host_record(&record_path, &record).unwrap()); + let _ = fs::remove_dir_all(record_path.parent().unwrap()); + + assert!(result.is_err()); + assert!( + elapsed < Duration::from_millis(250), + "partial host frames restarted the shutdown timeout: {elapsed:?}" + ); + } + #[test] fn termination_adoption_does_not_probe_legacy_protocols_for_receipt_hosts() { let (record_path, record, lease) = record_fixture("terminate-current-protocol"); @@ -7311,6 +9762,7 @@ mod unix { } #[test] + fn timed_out_cell_pixel_ack_reconciles_when_the_response_arrives_late() { let (record_path, record, lease) = record_fixture("late-cell-pixel-ack"); let (client, mut host) = UnixStream::pair().unwrap(); @@ -7767,7 +10219,6 @@ mod unix { assert!(!attachment.send_cell_pixel_size(9, 18).unwrap()); drop(attachment); fake_host.join().unwrap(); - let _ = fs::remove_file(endpoint); drop(lease); assert!(remove_stale_terminal_host_record(&record_path, &record).unwrap()); @@ -8191,6 +10642,142 @@ mod unix { parser.join().unwrap(); } + #[test] + fn new_client_uses_bit_four_with_a_merged_host() { + let host = exited_host_fixture(); + let (server_stream, mut client_stream) = UnixStream::pair().unwrap(); + client_stream.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let server_host = host.clone(); + let server = thread::spawn(move || serve_client(server_host, server_stream)); + + let hello = ClientHello { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + role: ClientRole::Admin, + requested_rights: CapabilityRights::TERMINATE, + terminal_id: host.terminal_id, + token: host.owner_token, + }; + let mut hello = hello.into_frame(1); + hello.flags = FLAG_TERMINATE_ONLY; + write_frame(&mut client_stream, &hello).unwrap(); + + let host_hello = read_required_frame(&mut client_stream, "host hello").unwrap(); + assert_eq!(host_hello.kind, MessageKind::HostHello); + assert_eq!(host_hello.flags, FLAG_TERMINATE_ONLY); + let mut terminate = Frame::new(MessageKind::Terminate, Vec::new()); + terminate.request_id = 2; + write_frame(&mut client_stream, &terminate).unwrap(); + let terminate_ack = + read_required_frame(&mut client_stream, "terminate acknowledgement").unwrap(); + assert_eq!(terminate_ack.kind, MessageKind::TerminateAck); + assert_eq!(terminate_ack.request_id, terminate.request_id); + client_stream.shutdown(std::net::Shutdown::Write).unwrap(); + + server.join().unwrap().unwrap(); + } + + #[test] + fn new_client_uses_record_gated_legacy_terminate_bits() { + for (record_version, expected_flag, fixture) in [ + (2, LEGACY_TERMINATE_ONLY_FLAG, "legacy-v2-terminate-bit"), + (3, LEGACY_V3_TERMINATE_ONLY_FLAG, "legacy-v3-terminate-bit"), + ] { + let (record_path, current_record, lease) = record_fixture(fixture); + let mut legacy_record = current_record.clone(); + legacy_record.record_version = record_version; + legacy_record.supports_terminate_ack = false; + let endpoint = PathBuf::from(&legacy_record.endpoint); + prepare_private_dir(endpoint.parent().unwrap()).unwrap(); + let _ = fs::remove_file(&endpoint); + let listener = UnixListener::bind(&endpoint).unwrap(); + let terminal_id = TerminalId::from_hex(&legacy_record.terminal_id).unwrap(); + let incarnation = HostIncarnation::from_hex(&legacy_record.incarnation).unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let hello_frame = + read_required_frame(&mut stream, "legacy client hello").unwrap(); + assert_eq!(hello_frame.version, SMART_RENDERER_PROTOCOL_VERSION); + assert_eq!(hello_frame.flags, expected_flag); + let hello = ClientHello::decode(&hello_frame.payload).unwrap(); + assert_eq!(hello.requested_rights, CapabilityRights::TERMINATE); + let response = HostHello { + selected_version: SMART_RENDERER_PROTOCOL_VERSION, + granted_rights: CapabilityRights::TERMINATE, + terminal_id, + incarnation, + }; + let mut response = Frame::new(MessageKind::HostHello, response.encode()); + response.version = SMART_RENDERER_PROTOCOL_VERSION; + response.flags = expected_flag; + response.request_id = hello_frame.request_id; + write_frame(&mut stream, &response).unwrap(); + let terminate = read_required_frame(&mut stream, "legacy terminate").unwrap(); + assert_eq!(terminate.kind, MessageKind::Terminate); + }); + + terminate_terminal_host_with_timeout( + &legacy_record, + &record_path, + Duration::from_secs(1), + ) + .unwrap(); + server.join().unwrap(); + + let _ = fs::remove_file(endpoint); + drop(lease); + assert!(remove_stale_terminal_host_record(&record_path, ¤t_record).unwrap()); + let _ = fs::remove_dir_all(record_path.parent().unwrap()); + } + } + + #[test] + fn new_client_rejects_old_main_record_without_terminate_capability() { + let (record_path, mut record, lease) = record_fixture("old-main-no-terminate"); + let current_record = record.clone(); + record.supports_terminate_only = false; + + let error = terminate_terminal_host_with_timeout( + &record, + &record_path, + Duration::from_millis(50), + ) + .unwrap_err(); + assert!( + error.to_string().contains("does not advertise"), + "unexpected rejection: {error:#}" + ); + + drop(lease); + assert!(remove_stale_terminal_host_record(&record_path, ¤t_record).unwrap()); + let _ = fs::remove_dir_all(record_path.parent().unwrap()); + } + + #[test] + fn old_branch_bit_two_terminate_client_is_rejected_by_merged_host() { + let host = exited_host_fixture(); + let (server_stream, mut client_stream) = UnixStream::pair().unwrap(); + client_stream.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let server_host = host.clone(); + let server = thread::spawn(move || serve_client(server_host, server_stream)); + + let hello = ClientHello { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + role: ClientRole::Admin, + requested_rights: CapabilityRights::TERMINATE, + terminal_id: host.terminal_id, + token: host.owner_token, + }; + let mut hello = hello.into_frame(1); + hello.flags = LEGACY_TERMINATE_ONLY_FLAG; + write_frame(&mut client_stream, &hello).unwrap(); + + assert!(read_required_frame(&mut client_stream, "host hello").is_err()); + assert!(server.join().unwrap().is_err()); + assert!(!host.termination_started.load(Ordering::Acquire)); + } + #[test] fn admin_owner_can_negotiate_the_smart_renderer_stream() { let host = exited_host_fixture(); @@ -9094,22 +11681,29 @@ mod unix { #[cfg(unix)] pub(crate) use unix::{ ControlResponses, DecodedHostResize, DeferredCellPixelResolution, - acquire_terminal_host_reset_lock, adopt_terminal_host_with_kitty_limits, - decode_host_resize_payload_for_version, load_terminal_host_records_for_reset, + acquire_terminal_host_publication, acquire_terminal_host_reset_lock, + adopt_terminal_host_with_kitty_limits, decode_host_resize_payload_for_version, + load_terminal_host_record, load_terminal_host_records_for_reset, }; #[cfg(unix)] pub use unix::{ HostAttachment, acknowledge_terminal_host_exit_record, adopt_terminal_host, decode_host_snapshot_payload, encode_host_snapshot_payload, isolate_terminal_host_process_fds, launch_terminal_host, launch_terminal_host_with_identity, load_terminal_host_exit_records, - load_terminal_host_records, remove_stale_terminal_host_record, serve_terminal_host_stdio, - terminal_host_exit_record, terminal_host_record_liveness, terminal_host_root, - validate_terminal_host_exit_record, validate_terminal_host_record, + load_terminal_host_records, load_terminal_host_records_strict, + remove_stale_terminal_host_record, serve_terminal_host_stdio, terminal_host_exit_record, + terminal_host_record_liveness, terminal_host_root, validate_terminal_host_exit_record, + validate_terminal_host_record, }; #[cfg(all(unix, test))] pub(crate) use unix::{ acquire_terminal_host_publication_lock, prepare_terminal_host_publication_lock, }; +#[cfg(unix)] +pub(crate) use unix::{ + launch_terminal_host_cancellable, launch_terminal_host_with_identity_cancellable, + terminate_and_confirm_terminal_host_record, +}; #[cfg(not(unix))] pub fn terminal_host_root(state_root: &Path, session: &str) -> PathBuf { @@ -9117,7 +11711,7 @@ pub fn terminal_host_root(state_root: &Path, session: &str) -> PathBuf { } #[cfg(not(unix))] -pub fn isolate_terminal_host_process_fds() -> anyhow::Result<()> { +pub fn isolate_terminal_host_process_fds(_args: &[String]) -> anyhow::Result<()> { Ok(()) } diff --git a/cmux-tui/crates/cmux-tui-core/src/unix_process_scope.rs b/cmux-tui/crates/cmux-tui-core/src/unix_process_scope.rs index 7f1c420d467d..6c3aa030f20b 100644 --- a/cmux-tui/crates/cmux-tui-core/src/unix_process_scope.rs +++ b/cmux-tui/crates/cmux-tui-core/src/unix_process_scope.rs @@ -1233,7 +1233,7 @@ struct ProcFileInfo { #[repr(C)] struct MacProcessUniqueInfo { _uuid: [u8; 16], - _unique_id: u64, + unique_id: u64, _parent_unique_id: u64, id_version: i32, _original_parent_id_version: i32, @@ -1248,6 +1248,19 @@ struct MacBsdInfoWithUniqueId { unique: MacProcessUniqueInfo, } +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MacProcessInstanceIdentity { + unique_id: u64, +} + +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MacProcessSignalIdentity { + pub(crate) instance: MacProcessInstanceIdentity, + pub(crate) pid_version: u32, +} + #[cfg(target_os = "macos")] #[repr(C)] struct MacAuditToken { @@ -1412,8 +1425,52 @@ fn process_identity(pid: u32) -> Option { Some(mac_process_snapshot(pid)?.identity) } +/// Return the kernel process instance identity, which remains stable across exec. +#[cfg(target_os = "macos")] +pub(crate) fn mac_process_instance_identity(pid: u32) -> Option { + let unique = mac_process_unique_info(pid)?; + Some(mac_process_instance_from_unique(&unique)) +} + +/// Return one instance-identity and PID-version record for exact signaling. +#[cfg(target_os = "macos")] +pub(crate) fn mac_process_signal_identity(pid: u32) -> Option { + let unique = mac_process_unique_info(pid)?; + Some(MacProcessSignalIdentity { + instance: mac_process_instance_from_unique(&unique), + pid_version: unique.id_version as u32, + }) +} + #[cfg(target_os = "macos")] fn mac_process_snapshot(pid: u32) -> Option { + let info = mac_process_info_with_unique_id(pid)?; + // Keep chronological start time in the high bits for scan filtering and + // the exec-stable process unique ID in the low bits for exact ownership. + let started = (u128::from(info.bsd.pbi_start_tvsec) << 64) | u128::from(info.unique.unique_id); + Some(ProcessSnapshot { identity: ProcessIdentity { pid, started }, parent: info.bsd.pbi_ppid }) +} + +#[cfg(target_os = "macos")] +fn mac_process_info_with_unique_id(pid: u32) -> Option { + if let Some(info) = mac_process_combined_info_with_unique_id(pid) { + return Some(info); + } + + // Some macOS process-security contexts deny the combined BSD and unique + // record. Bracket BSD metadata with the exec-stable unique ID so a + // recycled PID can never publish a replacement process as the prior owner. + let before = mac_process_unique_info(pid)?; + let bsd = mac_process_bsd_info(pid)?; + let after = mac_process_unique_info(pid)?; + if before.unique_id != after.unique_id { + return None; + } + Some(MacBsdInfoWithUniqueId { bsd, unique: after }) +} + +#[cfg(target_os = "macos")] +fn mac_process_combined_info_with_unique_id(pid: u32) -> Option { const PROC_PIDT_BSDINFOWITHUNIQID: libc::c_int = 18; let pid_int = libc::c_int::try_from(pid).ok()?; let mut info = std::mem::MaybeUninit::::zeroed(); @@ -1425,13 +1482,44 @@ fn mac_process_snapshot(pid: u32) -> Option { return None; } // SAFETY: proc_pidinfo initialized the full structure. - let info = unsafe { info.assume_init() }; - // Keep chronological start time in the high bits for scan filtering and - // the kernel PID version in the low bits for race-free signal delivery. - let started = (u128::from(info.bsd.pbi_start_tvsec) << 64) - | (u128::from(info.bsd.pbi_start_tvusec) << 32) - | u128::from(info.unique.id_version as u32); - Some(ProcessSnapshot { identity: ProcessIdentity { pid, started }, parent: info.bsd.pbi_ppid }) + Some(unsafe { info.assume_init() }) +} + +#[cfg(target_os = "macos")] +fn mac_process_unique_info(pid: u32) -> Option { + const PROC_PIDUNIQIDENTIFIERINFO: libc::c_int = 17; + let pid_int = libc::c_int::try_from(pid).ok()?; + let mut info = std::mem::MaybeUninit::::zeroed(); + let size = libc::c_int::try_from(size_of::()).ok()?; + let written = unsafe { + libc::proc_pidinfo(pid_int, PROC_PIDUNIQIDENTIFIERINFO, 0, info.as_mut_ptr().cast(), size) + }; + if written != size { + return None; + } + // SAFETY: proc_pidinfo initialized the full structure. + Some(unsafe { info.assume_init() }) +} + +#[cfg(target_os = "macos")] +fn mac_process_bsd_info(pid: u32) -> Option { + let pid_int = libc::c_int::try_from(pid).ok()?; + let mut info = std::mem::MaybeUninit::::zeroed(); + let size = libc::c_int::try_from(size_of::()).ok()?; + // SAFETY: info points to writable storage of exactly the requested size. + let written = unsafe { + libc::proc_pidinfo(pid_int, libc::PROC_PIDTBSDINFO, 0, info.as_mut_ptr().cast(), size) + }; + if written != size { + return None; + } + // SAFETY: proc_pidinfo initialized the full structure. + Some(unsafe { info.assume_init() }) +} + +#[cfg(target_os = "macos")] +fn mac_process_instance_from_unique(info: &MacProcessUniqueInfo) -> MacProcessInstanceIdentity { + MacProcessInstanceIdentity { unique_id: info.unique_id } } #[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] @@ -1476,6 +1564,22 @@ mod tests { assert!(!mac_environment_contains(&argv_only, expected)); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn process_scope_signals_the_current_exact_identity() { + let pid = std::process::id(); + #[cfg(target_os = "macos")] + { + let identity = process_identity(pid).unwrap(); + assert!(signal_process_with(identity, libc::SIGCONT)); + } + #[cfg(target_os = "linux")] + { + let pidfd = pidfd_open(pid).unwrap(); + pidfd_send_signal(&pidfd, libc::SIGCONT).unwrap(); + } + } + #[cfg(target_os = "macos")] #[test] fn mac_process_scope_launcher_stops_before_the_requested_program() { diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs index bdbb0b2288cd..f72b234e7e62 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs @@ -12,6 +12,8 @@ use std::collections::{HashMap, HashSet}; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use anyhow::Context; use fs4::FileExt; @@ -359,7 +361,9 @@ pub struct TerminalRegistryCommit { /// A replay cannot acquire a cross-domain side effect that was not part of /// its original transaction. Keep the receipt source explicit so the mux can -/// reconcile only the revision owned by that receipt. +/// reconcile only the revision owned by that receipt. Resource replays still +/// carry their committed revision so the in-memory projection can catch up +/// without terminating a live runtime. pub(crate) enum TerminalResourceCloseCommit { TerminalReplay(TerminalRegistryCommit), ResourceReplay { terminal: TerminalRegistryCommit, resource: ResourcePatchCommit }, @@ -645,6 +649,10 @@ pub struct WorkspaceRegistry { journal_after_commit_admission: Option<(std::sync::mpsc::SyncSender<()>, std::sync::mpsc::Receiver<()>)>, _lease: Option, + #[cfg(test)] + terminal_snapshot_count: AtomicUsize, + #[cfg(test)] + fail_terminal_record_reads: AtomicBool, _session_guard: Option, } @@ -1300,7 +1308,7 @@ fn remove_reset_dir_children_from_handle( )?; #[cfg(test)] inject_reset_delete_child_replacement(&child_display)?; - let staged_child = stage_reset_child_for_deletion( + let mut staged_child = stage_reset_child_for_deletion( directory.as_raw_fd(), &child_name, &child_display, @@ -1311,7 +1319,7 @@ fn remove_reset_dir_children_from_handle( &staged_child.name, &child_relative, &staged_child.display_path, - &staged_child.stat, + staged_child.stat(), expected_entries, ignored_root_child, ) { @@ -1324,7 +1332,7 @@ fn remove_reset_dir_children_from_handle( error, )); } - if reset_stat_is_dir(&staged_child.stat) { + if reset_stat_is_dir(staged_child.stat()) { let child_directory = open_reset_child_dir( directory.as_raw_fd(), &staged_child.name, @@ -1334,8 +1342,8 @@ fn remove_reset_dir_children_from_handle( format!("inspect {label} {}", staged_child.display_path.display()) })?; if !opened.file_type().is_dir() - || opened.dev() != reset_stat_device(&staged_child.stat) - || opened.ino() != reset_stat_inode(&staged_child.stat) + || opened.dev() != reset_stat_device(staged_child.stat()) + || opened.ino() != reset_stat_inode(staged_child.stat()) { anyhow::bail!( "reset path changed during reset: {}", @@ -1357,8 +1365,8 @@ fn remove_reset_dir_children_from_handle( &staged_child.display_path, )?; if !reset_stat_is_dir(¤t) - || reset_stat_device(¤t) != reset_stat_device(&staged_child.stat) - || reset_stat_inode(¤t) != reset_stat_inode(&staged_child.stat) + || reset_stat_device(¤t) != reset_stat_device(staged_child.stat()) + || reset_stat_inode(¤t) != reset_stat_inode(staged_child.stat()) { anyhow::bail!( "reset path changed during reset: {}", @@ -1379,6 +1387,7 @@ fn remove_reset_dir_children_from_handle( 0, )?; } + staged_child.commit_deletion(); } let remaining = reset_dir_child_names(directory, display_path, label)?; if !remaining.is_empty() { @@ -1422,9 +1431,39 @@ fn inject_reset_recreated_session_dir_after_staging(path: &Path) -> anyhow::Resu #[cfg(unix)] struct ResetStagedChild { + parent_fd: std::os::fd::RawFd, + original_name: std::ffi::OsString, + original_display_path: PathBuf, name: std::ffi::OsString, display_path: PathBuf, - stat: libc::stat, + stat: Option, + restore_on_drop: bool, +} + +#[cfg(unix)] +impl ResetStagedChild { + fn stat(&self) -> &libc::stat { + self.stat.as_ref().expect("staged reset child must have a stat") + } + + fn commit_deletion(&mut self) { + self.restore_on_drop = false; + } +} + +#[cfg(unix)] +impl Drop for ResetStagedChild { + fn drop(&mut self) { + if self.restore_on_drop { + let _ = reset_rename_child_exclusive( + self.parent_fd, + &self.name, + &self.original_name, + &self.display_path, + &self.original_display_path, + ); + } + } } #[cfg(unix)] @@ -1446,29 +1485,37 @@ fn stage_reset_child_for_deletion( &private_display, ) { Ok(()) => { - let stat = reset_child_stat(parent_fd, &private_name, &private_display)?; - if reset_stat_device(&stat) != reset_stat_device(expected) - || reset_stat_inode(&stat) != reset_stat_inode(expected) - || reset_stat_kind(&stat) != reset_stat_kind(expected) + let mut staged_child = ResetStagedChild { + parent_fd, + original_name: name.to_os_string(), + original_display_path: display_path.to_path_buf(), + name: private_name, + display_path: private_display, + stat: None, + restore_on_drop: true, + }; + staged_child.stat = Some(reset_child_stat( + parent_fd, + &staged_child.name, + &staged_child.display_path, + )?); + if reset_stat_device(staged_child.stat()) != reset_stat_device(expected) + || reset_stat_inode(staged_child.stat()) != reset_stat_inode(expected) + || reset_stat_kind(staged_child.stat()) != reset_stat_kind(expected) { - let error = anyhow::anyhow!( - "reset path changed during reset: {}", - display_path.display() - ); return Err(restore_changed_reset_child( parent_fd, - &private_name, + &staged_child.name, name, - &private_display, + &staged_child.display_path, display_path, - error, + anyhow::anyhow!( + "reset path changed during reset: {}", + display_path.display() + ), )); } - return Ok(ResetStagedChild { - name: private_name, - display_path: private_display, - stat, - }); + return Ok(staged_child); } Err(error) if error @@ -2642,6 +2689,10 @@ impl WorkspaceRegistry { journal_after_commit_admission: None, _session_guard: session_guard, _lease: lease, + #[cfg(test)] + terminal_snapshot_count: AtomicUsize::new(0), + #[cfg(test)] + fail_terminal_record_reads: AtomicBool::new(false), }) } @@ -2777,6 +2828,10 @@ impl WorkspaceRegistry { &self.generation } + pub fn terminal_revision(&self) -> anyhow::Result { + current_terminal_revision(&self.connection) + } + pub fn session_id(&self) -> &SessionPublicId { &self.session_id } @@ -2788,6 +2843,8 @@ impl WorkspaceRegistry { /// Returns the canonical, non-tombstoned terminal placement projection. /// Runtime surface ids and renderer process ids are intentionally absent. pub fn terminal_snapshot(&self) -> anyhow::Result { + #[cfg(test)] + self.terminal_snapshot_count.fetch_add(1, Ordering::Relaxed); let revision = current_terminal_revision(&self.connection)?; let mut statement = self.connection.prepare( "SELECT terminal_id, workspace_key, incarnation, lifecycle, @@ -2816,13 +2873,44 @@ impl WorkspaceRegistry { }) } + #[cfg(test)] + pub(crate) fn reset_terminal_snapshot_count_for_test(&self) { + self.terminal_snapshot_count.store(0, Ordering::Relaxed); + } + + #[cfg(test)] + pub(crate) fn terminal_snapshot_count_for_test(&self) -> usize { + self.terminal_snapshot_count.load(Ordering::Relaxed) + } + /// Includes tombstones and is intended for reconciliation and idempotent /// close handling, not frontend materialization. pub fn terminal_record(&self, terminal_id: &str) -> anyhow::Result> { + #[cfg(test)] + if self.fail_terminal_record_reads.load(Ordering::Relaxed) { + anyhow::bail!("forced terminal record read failure"); + } validate_terminal_identity("terminal id", terminal_id)?; read_terminal(&self.connection, terminal_id) } + #[cfg(test)] + pub(crate) fn set_terminal_record_read_failure_for_test(&self, enabled: bool) { + self.fail_terminal_record_reads.store(enabled, Ordering::Relaxed); + } + + pub(crate) fn terminal_ids_including_tombstones(&self) -> anyhow::Result> { + #[cfg(test)] + self.terminal_snapshot_count.fetch_add(1, Ordering::Relaxed); + let mut statement = self + .connection + .prepare("SELECT terminal_id FROM terminal_hosts ORDER BY terminal_id ASC")?; + statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>() + .map_err(Into::into) + } + pub fn replay_terminal( &self, mutation: &WorkspaceMutation, diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs index e910b9253d3d..09a250a1bca6 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs @@ -850,7 +850,7 @@ mod tests { } #[test] - fn agent_projections_survive_terminal_tombstones() { + fn agent_projections_are_deleted_with_terminal_tombstones() { let mut registry = WorkspaceRegistry::in_memory("terminal-relationships").unwrap(); let session = registry.session_id().clone(); let (terminal, pane, tab) = seed_live_terminal(&mut registry); @@ -914,7 +914,7 @@ mod tests { }), ResourceChange::TombstoneTab { tab_id: tab, close_content: true }, ResourceChange::TombstoneTerminal { - public_id: terminal.clone(), + public_id: terminal, expected_incarnation: None, }, ResourceChange::SetTabOrder { pane_id: pane, tab_ids: Vec::new() }, @@ -926,9 +926,8 @@ mod tests { .unwrap(); let tombstoned = registry.public_projections().unwrap(); - assert_eq!(registry.resource_agent_projection_count_for_test().unwrap(), 1); - assert_eq!(tombstoned.agents.len(), 1); - assert_eq!(tombstoned.agents[0].terminal_id, terminal); + assert_eq!(registry.resource_agent_projection_count_for_test().unwrap(), 0); + assert!(tombstoned.agents.is_empty()); assert_eq!(tombstoned.notifications.len(), 1); assert_eq!(tombstoned.notifications[0].terminal_id, None); } diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs index 9506ab0c5098..db49f3d66b36 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs @@ -150,7 +150,13 @@ pub(super) fn create_resource_schema(transaction: &Transaction<'_>) -> anyhow::R ) ) ); - DROP TRIGGER IF EXISTS resource_agent_projection_terminal_tombstone; + CREATE TRIGGER IF NOT EXISTS resource_agent_projection_terminal_tombstone + AFTER UPDATE OF deleted_revision ON resource_terminals + WHEN NEW.deleted_revision IS NOT NULL + BEGIN + DELETE FROM resource_agent_projections + WHERE terminal_id = NEW.public_id; + END; CREATE INDEX IF NOT EXISTS resource_mutations_by_operation_revision ON resource_mutations(operation, committed_revision DESC); CREATE INDEX IF NOT EXISTS resource_agent_projections_by_revision @@ -297,6 +303,7 @@ pub(super) fn migrate_resource_agent_projections( FROM ranked JOIN resource_terminals AS terminal ON terminal.public_id = ranked.terminal_id + AND terminal.deleted_revision IS NULL WHERE ranked.terminal_rank = 1 ON CONFLICT(terminal_id) DO UPDATE SET result_json = excluded.result_json, @@ -544,6 +551,24 @@ impl WorkspaceRegistry { .collect() } + pub(crate) fn terminal_host_identities_in_workspace( + &self, + workspace_key: &str, + ) -> anyhow::Result)>> { + validate_workspace_key(workspace_key)?; + let mut statement = self.connection.prepare( + "SELECT terminal_id, incarnation + FROM terminal_hosts + WHERE workspace_key = ?1 + AND lifecycle != 'tombstoned' + ORDER BY created_revision ASC, terminal_id ASC", + )?; + statement + .query_map([workspace_key], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>() + .map_err(Into::into) + } + /// Resolve the immutable resource-to-host relationship, including after /// explicit close, so lifecycle reads can distinguish tombstones from /// identifiers that never existed. diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs index e5d68e005ce3..d6d2a9803b60 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs @@ -572,6 +572,7 @@ fn terminal_host_reset_holds_structured_live_marker_lock() { workspace_key: String::new(), supports_set_defaults: true, supports_clear_history: true, + supports_terminate_only: false, supports_terminate_ack: false, }; let record_path = record.record_path(&root); @@ -665,6 +666,7 @@ fn terminal_host_reset_checks_legacy_live_marker_as_orphan() { workspace_key: String::new(), supports_set_defaults: false, supports_clear_history: false, + supports_terminate_only: false, supports_terminate_ack: false, }; let record_path = record.record_path(&root); @@ -772,6 +774,7 @@ fn reset_accepts_dead_v2_terminal_host_without_creating_live_marker() { workspace_key: String::new(), supports_set_defaults: true, supports_clear_history: true, + supports_terminate_only: false, supports_terminate_ack: false, }; let record_path = record.record_path(&host_root); @@ -3844,16 +3847,18 @@ fn schema_seven_resumes_interrupted_sensitive_receipt_cleanup() { } #[test] -fn schema_seven_migrates_latest_agent_and_preserves_it_after_tombstone() { - assert_schema_migrates_latest_agent_and_preserves_it_after_tombstone(7); +fn schema_seven_migrates_latest_live_agent_and_tombstones_without_resurrection() { + assert_schema_migrates_latest_live_agent_and_tombstones_without_resurrection(7); } #[test] -fn schema_eight_migrates_latest_agent_and_preserves_it_after_tombstone() { - assert_schema_migrates_latest_agent_and_preserves_it_after_tombstone(8); +fn schema_eight_migrates_latest_live_agent_and_tombstones_without_resurrection() { + assert_schema_migrates_latest_live_agent_and_tombstones_without_resurrection(8); } -fn assert_schema_migrates_latest_agent_and_preserves_it_after_tombstone(legacy_schema: u32) { +fn assert_schema_migrates_latest_live_agent_and_tombstones_without_resurrection( + legacy_schema: u32, +) { let root = temp_root(&format!("schema-{legacy_schema}-agent-projection")); let database = root.join(session_storage_component("session")).join(WORKSPACE_REGISTRY_FILE); let terminal = terminal_resource(TERMINAL_ONE); @@ -3932,7 +3937,7 @@ fn assert_schema_migrates_latest_agent_and_preserves_it_after_tombstone(legacy_s |row| row.get(0), ) .unwrap(); - assert_eq!(legacy_trigger_count, 0); + assert_eq!(legacy_trigger_count, 1); let agents = migrated.public_projections().unwrap().agents; assert_eq!(agents.len(), 1); assert_eq!(agents[0].terminal_id, terminal); @@ -3968,12 +3973,12 @@ fn assert_schema_migrates_latest_agent_and_preserves_it_after_tombstone(legacy_s &json!([]), ) .unwrap(); - assert_eq!(migrated.resource_agent_projection_count_for_test().unwrap(), 1); - assert_eq!(migrated.public_projections().unwrap().agents.len(), 1); + assert_eq!(migrated.resource_agent_projection_count_for_test().unwrap(), 0); + assert!(migrated.public_projections().unwrap().agents.is_empty()); drop(migrated); - // Re-running the legacy migration recovers the durable projection from the - // latest report even though its terminal is already tombstoned. + // Re-running the legacy migration must not recreate an agent projection + // for a terminal that is already tombstoned. Connection::open(&database) .unwrap() .execute_batch(&format!( @@ -3983,8 +3988,8 @@ fn assert_schema_migrates_latest_agent_and_preserves_it_after_tombstone(legacy_s )) .unwrap(); let reopened = WorkspaceRegistry::open(&root, "session").unwrap(); - assert_eq!(reopened.resource_agent_projection_count_for_test().unwrap(), 1); - assert_eq!(reopened.public_projections().unwrap().agents.len(), 1); + assert_eq!(reopened.resource_agent_projection_count_for_test().unwrap(), 0); + assert!(reopened.public_projections().unwrap().agents.is_empty()); drop(reopened); fs::remove_dir_all(root).unwrap(); } diff --git a/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs b/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs index 728b36c5521f..814ef368d6fd 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs @@ -38,6 +38,16 @@ fn write_json(ws: &mut tungstenite::WebSocket, value: Value ws.send(Message::Text(value.to_string().into())).unwrap(); } +fn write_target_list( + ws: &mut tungstenite::WebSocket, + id: Value, + targets: &[&str], +) { + let target_infos = + targets.iter().map(|target_id| json!({"targetId": target_id})).collect::>(); + write_json(ws, json!({"id": id, "result": {"targetInfos": target_infos}})); +} + fn write_main_frame_commit( ws: &mut tungstenite::WebSocket, session_id: &str, @@ -1017,7 +1027,7 @@ fn socket_browser_attach_streams_frames_input_and_cell_pixels() { assert_eq!(failed, "net::ERR_NAME_NOT_RESOLVED"); mux.close_surface(surface).unwrap(); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); server.join().unwrap(); } @@ -1163,7 +1173,7 @@ fn wedged_browser_navigate_does_not_block_same_socket_connection() { for _ in 0..2 { let _ = release_tx.send(()); } - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); server.join().unwrap(); } @@ -1313,7 +1323,7 @@ fn queued_back_and_forward_do_not_collapse_while_worker_is_blocked() { ); mux.close_surface(surface).unwrap(); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); server.join().unwrap(); } @@ -1437,7 +1447,7 @@ fn control_command_reports_backpressure_when_worker_queue_is_full() { release_tx.send(()).unwrap(); mux.close_surface(surface).unwrap(); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); server.join().unwrap(); } @@ -1448,6 +1458,7 @@ fn browser_capture_scale_applies_to_metrics_screencast_and_input() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let (seen_tx, seen_rx) = mpsc::channel(); + let (transport_closed_tx, transport_closed_rx) = mpsc::sync_channel(1); let server = thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); @@ -1471,6 +1482,9 @@ fn browser_capture_scale_applies_to_metrics_screencast_and_input() { | "Input.dispatchMouseEvent" => { write_json(&mut ws, json!({"id": id, "result": {}})); } + "Target.getTargets" => { + write_target_list(&mut ws, id, &["target-1"]); + } "Page.startScreencast" => { write_json(&mut ws, json!({"id": id, "result": {}})); write_json( @@ -1496,6 +1510,7 @@ fn browser_capture_scale_applies_to_metrics_screencast_and_input() { method => panic!("unexpected CDP method {method}"), } } + transport_closed_tx.send(()).unwrap(); }); let opts = SurfaceOptions { browser_max_capture_megapixels: 0.01, ..Default::default() }; @@ -1512,6 +1527,7 @@ fn browser_capture_scale_applies_to_metrics_screencast_and_input() { let surface = mux .new_browser_tab("example.test".to_string(), None, Some((100, 100))) .expect("browser tab"); + let retained_surface_id = surface.id; let _provider = browser_provider( &socket_path, format!("ws://{addr}/devtools/browser/fake"), @@ -1556,8 +1572,12 @@ fn browser_capture_scale_applies_to_metrics_screencast_and_input() { assert_eq!(mouse["params"]["x"], 50.0); assert_eq!(mouse["params"]["y"], 50.0); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); + transport_closed_rx + .recv_timeout(test_duration(Duration::from_secs(10))) + .expect("CDP transport remained open after browser runtime shutdown"); + assert_eq!(surface.id, retained_surface_id); server.join().unwrap(); } @@ -1592,6 +1612,9 @@ fn stalled_external_browser_nudges_target_once_before_interaction() { | "Input.dispatchMouseEvent" => { write_json(&mut ws, json!({"id": id, "result": {}})); } + "Target.getTargets" => { + write_target_list(&mut ws, id, &["target-1"]); + } "Page.startScreencast" => { write_json(&mut ws, json!({"id": id, "result": {}})); write_json( @@ -1663,7 +1686,7 @@ fn stalled_external_browser_nudges_target_once_before_interaction() { assert_eq!(second_mouse["method"], "Input.dispatchMouseEvent"); assert_eq!(second_mouse["params"]["x"], 13.0); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); server.join().unwrap(); } @@ -1766,7 +1789,7 @@ fn provider_disconnect_reconnects_without_closing_canonical_browser_topology() { mux.close_surface(surface.id).unwrap(); drop(provider); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); first_server.join().unwrap(); second_server.join().unwrap(); @@ -1887,7 +1910,7 @@ fn provider_target_revision_reattaches_on_the_same_browser_connection() { mux.close_surface(surface.id).unwrap(); assert_eq!(detached_rx.recv_timeout(Duration::from_secs(10)).unwrap(), "session-second"); drop(provider); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); cdp_server.join().unwrap(); } @@ -1942,6 +1965,6 @@ fn browser_tab_creation_is_async_and_surfaces_bootstrap_failure() { || status.contains("timed out"), "{status}" ); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); } diff --git a/cmux-tui/crates/cmux-tui-core/tests/build_source_identity.rs b/cmux-tui/crates/cmux-tui-core/tests/build_source_identity.rs new file mode 100644 index 000000000000..a771ab321597 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/tests/build_source_identity.rs @@ -0,0 +1,3 @@ +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/cmux-tui/crates/cmux-tui-core/tests/pty.rs b/cmux-tui/crates/cmux-tui-core/tests/pty.rs index a414d5419c2f..e14aec6c2b94 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/pty.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/pty.rs @@ -276,7 +276,7 @@ fn headless_creation_uses_explicit_or_authoritative_client_size() { .unwrap(); assert_vt_state_size(&mut writer, &mut reader, 15, clamped, (1, 1)); - mux.shutdown(); + mux.shutdown().unwrap(); cmux_tui_core::server::cleanup(&sock_path); } @@ -301,7 +301,7 @@ fn terminal_surface_uses_only_its_explicit_geometry_authority() { assert!(!mux.resize_surface_for_client(surface, 1, 60, 18).unwrap()); assert_eq!(mux.surface(surface).unwrap().size(), (100, 32)); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -328,7 +328,7 @@ fn surface_exit_detaches_terminal_view_and_emits_event() { assert_eq!(state.workspaces.len(), 1); assert!(state.workspaces[0].screens.is_empty()); }); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/tests/websocket_transport.rs b/cmux-tui/crates/cmux-tui-core/tests/websocket_transport.rs index b7b0755d8779..3ddb89b32d96 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/websocket_transport.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/websocket_transport.rs @@ -112,7 +112,7 @@ fn websocket_server_allows_pairing_without_a_static_token() { assert!(server.is_ok(), "WebSocket listener rejected pairing mode"); } - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -126,7 +126,7 @@ fn websocket_server_rejects_tokens_that_cannot_fit_the_auth_limit() { ); assert!(result.is_err(), "WebSocket listener accepted an unusably large token"); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -147,7 +147,7 @@ fn websocket_rejects_oversized_authentication_frames() { "oversized pre-authentication frame remained accepted" ); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -171,7 +171,7 @@ fn websocket_keeps_authenticated_inbound_requests_at_four_mib() { let mut next = authenticated_connect(server.local_addr()); send_json(&mut next, json!({"id": 1, "cmd": "ping"})); assert_eq!(read_until(&mut next, |value| value["id"] == 1)["ok"], true); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -207,7 +207,7 @@ fn websocket_auth_accepts_exact_preamble_and_rejects_missing_or_wrong_tokens() { assert_eq!(identify["data"]["protocol"], server::PROTOCOL_VERSION); assert_eq!(identify["data"]["session"], "ws-auth"); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -250,7 +250,7 @@ fn websocket_pairing_is_approved_over_trusted_unix_and_credential_reconnects() { send_json(&mut reconnect, json!({"id": 4, "cmd": "identify"})); assert_eq!(read_until(&mut reconnect, |value| value["id"] == 4)["ok"], true); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); } @@ -311,7 +311,7 @@ fn websocket_streams_subscribe_and_attach_and_survives_unclean_disconnect() { assert_eq!(identify["ok"], true); assert_eq!(identify["data"]["protocol"], server::PROTOCOL_VERSION); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -357,7 +357,7 @@ fn websocket_render_attach_carries_large_rgba_state_and_stays_connected() { assert_eq!(ping["data"]["ok"], true); eprintln!("WebSocket render-state and follow-up request bytes: {render_state_bytes}"); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -414,7 +414,7 @@ fn websocket_subscriber_receives_cross_connection_event_without_poll_delay() { ); assert_eq!(read_until(&mut trigger, |value| value["id"] == 3)["ok"], true); - mux.shutdown(); + mux.shutdown().unwrap(); } #[test] @@ -579,7 +579,7 @@ fn clients_list_identify_resize_and_detach_across_transports() { serde_json::from_str::(&trailing).expect("trailing frame before EOF must be JSON"); } - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket_path); } @@ -610,5 +610,5 @@ fn websocket_non_loopback_bind_requires_and_accepts_explicit_insecure_opt_in() { assert_eq!(identify["ok"], true); assert_eq!(identify["data"]["protocol"], server::PROTOCOL_VERSION); - mux.shutdown(); + mux.shutdown().unwrap(); } diff --git a/cmux-tui/crates/cmux-tui-process/Cargo.toml b/cmux-tui/crates/cmux-tui-process/Cargo.toml new file mode 100644 index 000000000000..a6662ba7bc3e --- /dev/null +++ b/cmux-tui/crates/cmux-tui-process/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cmux-tui-process" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "Process creation coordination for cmux-tui" + +[lints] +workspace = true + +[dependencies] +tokio.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +libc.workspace = true +socket2 = { workspace = true, features = ["all"] } diff --git a/cmux-tui/crates/cmux-tui-process/src/lib.rs b/cmux-tui/crates/cmux-tui-process/src/lib.rs new file mode 100644 index 000000000000..9f000336357d --- /dev/null +++ b/cmux-tui/crates/cmux-tui-process/src/lib.rs @@ -0,0 +1,495 @@ +//! Process creation coordination shared by every cmux-tui runtime crate. + +use std::io; +use std::process::{Child, Command}; + +#[cfg(target_os = "macos")] +static PROCESS_CREATION_BARRIER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Holds the process-wide child-creation barrier. +/// +/// macOS cannot create every socket descriptor with close-on-exec set +/// atomically. Socket setup holds this guard until `FD_CLOEXEC` is set, while +/// every child launch holds the same guard until the child exists. +/// Library-backed process launch paths, such as PTY creation, must acquire +/// this guard around their spawn call. +#[must_use = "dropping the guard releases process creation"] +pub struct ProcessCreationGuard { + #[cfg(target_os = "macos")] + _guard: std::sync::MutexGuard<'static, ()>, +} + +impl ProcessCreationGuard { + pub fn acquire() -> Self { + #[cfg(target_os = "macos")] + { + let guard = + PROCESS_CREATION_BARRIER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + Self { _guard: guard } + } + #[cfg(not(target_os = "macos"))] + { + Self {} + } + } + + /// Acquire the process barrier without exceeding an operation deadline. + pub fn acquire_until(deadline: std::time::Instant) -> io::Result { + #[cfg(target_os = "macos")] + { + loop { + match PROCESS_CREATION_BARRIER.try_lock() { + Ok(guard) => return Ok(Self { _guard: guard }), + Err(std::sync::TryLockError::Poisoned(error)) => { + return Ok(Self { _guard: error.into_inner() }); + } + Err(std::sync::TryLockError::WouldBlock) => {} + } + let remaining = deadline + .checked_duration_since(std::time::Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "process creation barrier acquisition timed out", + ) + })?; + std::thread::sleep(remaining.min(std::time::Duration::from_millis(1))); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = deadline; + Ok(Self {}) + } + } +} + +/// Spawn a child while excluding non-atomic close-on-exec descriptor setup. +pub fn spawn(command: &mut Command) -> io::Result { + let _guard = ProcessCreationGuard::acquire(); + command.spawn() +} + +/// Spawn a child only while the process barrier deadline remains valid. +pub fn spawn_until(command: &mut Command, deadline: std::time::Instant) -> io::Result { + let _guard = ProcessCreationGuard::acquire_until(deadline)?; + if std::time::Instant::now() >= deadline { + return Err(io::Error::new(io::ErrorKind::TimedOut, "process spawn deadline expired")); + } + command.spawn() +} + +/// Unix socket operations whose descriptor creation is coordinated with +/// process launch on macOS. +#[cfg(unix)] +pub mod unix { + use std::io; + #[cfg(target_os = "macos")] + use std::os::fd::AsRawFd; + use std::os::unix::net::{SocketAddr, UnixListener, UnixStream}; + use std::path::Path; + + use super::ProcessCreationGuard; + + /// Bind a Unix listener without exposing its descriptor to a concurrent + /// child launch. + pub fn bind_listener(path: impl AsRef) -> io::Result { + let _guard = ProcessCreationGuard::acquire(); + UnixListener::bind(path) + } + + /// Connect a Unix stream without exposing its descriptor to a concurrent + /// child launch. + pub fn connect_stream(path: impl AsRef) -> io::Result { + #[cfg(target_os = "macos")] + { + let address = socket2::SockAddr::unix(path)?; + let socket = { + let _guard = ProcessCreationGuard::acquire(); + socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)? + }; + socket.connect(&address)?; + Ok(socket.into()) + } + #[cfg(not(target_os = "macos"))] + { + UnixStream::connect(path) + } + } + + /// Create a connected Unix stream pair without exposing either descriptor + /// to a concurrent child launch. + pub fn pair_stream() -> io::Result<(UnixStream, UnixStream)> { + let _guard = ProcessCreationGuard::acquire(); + UnixStream::pair() + } + + /// Clone a Unix stream without exposing the duplicate descriptor to a + /// concurrent child launch. + pub fn clone_stream(stream: &UnixStream) -> io::Result { + let _guard = ProcessCreationGuard::acquire(); + stream.try_clone() + } + + /// Clone a Unix listener without exposing the duplicate descriptor to a + /// concurrent child launch. + pub fn clone_listener(listener: &UnixListener) -> io::Result { + let _guard = ProcessCreationGuard::acquire(); + listener.try_clone() + } + + /// Accept a Unix stream without exposing the accepted descriptor to a + /// concurrent child launch. + pub fn accept_stream(listener: &UnixListener) -> io::Result<(UnixStream, SocketAddr)> { + #[cfg(target_os = "macos")] + { + accept_macos(listener.as_raw_fd(), || listener.accept()) + } + #[cfg(not(target_os = "macos"))] + { + listener.accept() + } + } + + #[cfg(target_os = "macos")] + pub(super) fn accept_macos( + descriptor: libc::c_int, + mut accept: impl FnMut() -> io::Result, + ) -> io::Result { + loop { + let blocking = descriptor_is_blocking(descriptor)?; + if blocking { + wait_until_readable(descriptor)?; + } + let _guard = ProcessCreationGuard::acquire(); + match accept() { + Err(error) + if blocking + && matches!( + error.kind(), + io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock + ) => {} + result => return result, + } + } + } + + #[cfg(target_os = "macos")] + fn descriptor_is_blocking(descriptor: libc::c_int) -> io::Result { + // SAFETY: F_GETFL only reads flags from this valid listener descriptor. + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFL) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + Ok(flags & libc::O_NONBLOCK == 0) + } + + #[cfg(target_os = "macos")] + fn wait_until_readable(descriptor: libc::c_int) -> io::Result<()> { + loop { + let mut poll_descriptor = + libc::pollfd { fd: descriptor, events: libc::POLLIN, revents: 0 }; + // SAFETY: poll_descriptor points to one initialized pollfd for + // the duration of the blocking readiness wait. + let result = unsafe { libc::poll(&raw mut poll_descriptor, 1, -1) }; + if result > 0 { + if poll_descriptor.revents & libc::POLLNVAL != 0 { + return Err(io::Error::from_raw_os_error(libc::EBADF)); + } + return Ok(()); + } + if result == 0 { + continue; + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } + } +} + +/// TCP socket operations whose descriptor creation is coordinated with +/// process launch on macOS. +pub mod tcp { + use std::io; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::time::Duration; + #[cfg(target_os = "macos")] + use std::time::Instant; + + use super::ProcessCreationGuard; + + /// Bind a TCP listener without exposing its descriptor to a concurrent + /// child launch. + pub fn bind_listener(address: SocketAddr) -> io::Result { + let _guard = ProcessCreationGuard::acquire(); + TcpListener::bind(address) + } + + /// Connect a TCP stream without exposing its descriptor to a concurrent + /// child launch. + pub fn connect_stream(address: SocketAddr) -> io::Result { + #[cfg(target_os = "macos")] + { + let socket = new_stream_socket(address)?; + socket.connect(&address.into())?; + Ok(socket.into()) + } + #[cfg(not(target_os = "macos"))] + { + TcpStream::connect(address) + } + } + + /// Connect a bounded TCP stream without exposing its descriptor to a + /// concurrent child launch. + pub fn connect_stream_timeout( + address: &SocketAddr, + timeout: Duration, + ) -> io::Result { + #[cfg(target_os = "macos")] + { + let deadline = Instant::now() + timeout; + let socket = new_stream_socket_until(*address, deadline)?; + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::TimedOut, "TCP connection timed out") + })?; + socket.connect_timeout(&(*address).into(), remaining)?; + Ok(socket.into()) + } + #[cfg(not(target_os = "macos"))] + { + TcpStream::connect_timeout(address, timeout) + } + } + + /// Clone a TCP stream without exposing the duplicate descriptor to a + /// concurrent child launch. + pub fn clone_stream(stream: &TcpStream) -> io::Result { + let _guard = ProcessCreationGuard::acquire(); + stream.try_clone() + } + + /// Clone a TCP listener without exposing the duplicate descriptor to a + /// concurrent child launch. + pub fn clone_listener(listener: &TcpListener) -> io::Result { + let _guard = ProcessCreationGuard::acquire(); + listener.try_clone() + } + + /// Accept a TCP stream without exposing the accepted descriptor to a + /// concurrent child launch. + pub fn accept_stream(listener: &TcpListener) -> io::Result<(TcpStream, SocketAddr)> { + #[cfg(target_os = "macos")] + { + use std::os::fd::AsRawFd; + + super::unix::accept_macos(listener.as_raw_fd(), || listener.accept()) + } + #[cfg(not(target_os = "macos"))] + { + listener.accept() + } + } + + #[cfg(target_os = "macos")] + fn new_stream_socket(address: SocketAddr) -> io::Result { + let _guard = ProcessCreationGuard::acquire(); + socket2::Socket::new( + socket2::Domain::for_address(address), + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + ) + } + + #[cfg(target_os = "macos")] + fn new_stream_socket_until( + address: SocketAddr, + deadline: Instant, + ) -> io::Result { + let _guard = ProcessCreationGuard::acquire_until(deadline)?; + socket2::Socket::new( + socket2::Domain::for_address(address), + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + ) + } +} + +/// Tokio socket operations coordinated with process launch on macOS. +/// +/// Tokio and Mio cannot create or accept every macOS socket with +/// close-on-exec set atomically. These helpers hold the same process-wide +/// guard used by child launch only around descriptor creation and registration. +pub mod tokio_net { + use std::io; + use std::net::SocketAddr; + + use tokio::net::{TcpListener, TcpSocket, TcpStream}; + + use super::ProcessCreationGuard; + + /// Bind and register a Tokio TCP listener without an inheritance window. + pub fn bind_tcp_listener(address: SocketAddr) -> io::Result { + let listener = super::tcp::bind_listener(address)?; + listener.set_nonblocking(true)?; + TcpListener::from_std(listener) + } + + /// Connect to the first reachable TCP address without an inheritance window. + pub async fn connect_tcp_stream(addresses: &[SocketAddr]) -> io::Result { + let mut last_error = None; + for &address in addresses { + let socket = { + let _guard = ProcessCreationGuard::acquire(); + if address.is_ipv4() { TcpSocket::new_v4() } else { TcpSocket::new_v6() } + }?; + match socket.connect(address).await { + Ok(stream) => return Ok(stream), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "TCP address list is empty") + })) + } + + /// Accept a Tokio TCP stream without an inheritance window. + pub async fn accept_tcp_stream(listener: &TcpListener) -> io::Result<(TcpStream, SocketAddr)> { + #[cfg(target_os = "macos")] + { + std::future::poll_fn(|context| { + let _guard = ProcessCreationGuard::acquire(); + listener.poll_accept(context) + }) + .await + } + #[cfg(not(target_os = "macos"))] + { + listener.accept().await + } + } + + #[cfg(unix)] + use std::path::Path; + #[cfg(unix)] + use tokio::net::{UnixListener, UnixStream}; + + /// Bind and register a Tokio Unix listener without an inheritance window. + #[cfg(unix)] + pub fn bind_unix_listener(path: impl AsRef) -> io::Result { + let listener = super::unix::bind_listener(path)?; + listener.set_nonblocking(true)?; + UnixListener::from_std(listener) + } + + /// Connect a Tokio Unix stream without an inheritance window. + #[cfg(unix)] + pub async fn connect_unix_stream(path: impl AsRef) -> io::Result { + #[cfg(target_os = "macos")] + { + let address = socket2::SockAddr::unix(path)?; + let (stream, pending) = { + let _guard = ProcessCreationGuard::acquire(); + let socket = + socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)?; + socket.set_nonblocking(true)?; + let pending = match socket.connect(&address) { + Ok(()) => false, + Err(error) if connect_is_pending(&error) => true, + Err(error) => return Err(error), + }; + let stream: std::os::unix::net::UnixStream = socket.into(); + (stream, pending) + }; + let stream = UnixStream::from_std(stream)?; + if pending { + stream.writable().await?; + if let Some(error) = stream.take_error()? { + return Err(error); + } + } + Ok(stream) + } + #[cfg(not(target_os = "macos"))] + { + UnixStream::connect(path).await + } + } + + /// Accept a Tokio Unix stream without an inheritance window. + #[cfg(unix)] + pub async fn accept_unix_stream( + listener: &UnixListener, + ) -> io::Result<(UnixStream, tokio::net::unix::SocketAddr)> { + #[cfg(target_os = "macos")] + { + std::future::poll_fn(|context| { + let _guard = ProcessCreationGuard::acquire(); + listener.poll_accept(context) + }) + .await + } + #[cfg(not(target_os = "macos"))] + { + listener.accept().await + } + } + + #[cfg(target_os = "macos")] + fn connect_is_pending(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::WouldBlock + || matches!( + error.raw_os_error(), + Some(libc::EINPROGRESS | libc::EALREADY | libc::EWOULDBLOCK) + ) + } +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use std::time::Duration; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn tokio_tcp_connect_waits_for_process_creation_barrier() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (held_sender, held_receiver) = std::sync::mpsc::channel(); + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _barrier = super::ProcessCreationGuard::acquire(); + held_sender.send(()).unwrap(); + release_receiver.recv().unwrap(); + }); + held_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("process creation barrier was not acquired"); + + let (started_sender, started_receiver) = tokio::sync::oneshot::channel(); + let mut connect = tokio::spawn(async move { + started_sender.send(()).unwrap(); + super::tokio_net::connect_tcp_stream(&[address]).await + }); + started_receiver.await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(250), &mut connect).await.is_err(), + "TCP connect created a socket while a concurrent process could inherit it" + ); + release_sender.send(()).unwrap(); + holder.join().unwrap(); + + let stream = tokio::time::timeout(Duration::from_secs(5), connect) + .await + .expect("TCP connect did not resume after the process barrier was released") + .unwrap() + .unwrap(); + let (peer, _) = listener.accept().unwrap(); + drop(peer); + drop(stream); + } +} diff --git a/cmux-tui/crates/cmux-tui/Cargo.toml b/cmux-tui/crates/cmux-tui/Cargo.toml index 0c7b7916adab..303fb0b97b03 100644 --- a/cmux-tui/crates/cmux-tui/Cargo.toml +++ b/cmux-tui/crates/cmux-tui/Cargo.toml @@ -20,7 +20,9 @@ path = "src/bin/cmux-tui-hook.rs" [dependencies] ghostty-vt.workspace = true +cmux-tui-cdp.workspace = true cmux-tui-core.workspace = true +cmux-tui-process.workspace = true cmux-tui-machine-agent-protocol.workspace = true cmux-tui-machine-protocol.workspace = true ratatui.workspace = true diff --git a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs index eb0ea48fa2dd..82857417aabf 100644 --- a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs +++ b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs @@ -744,7 +744,8 @@ fn run_hermes_command_with_timeout( command.creation_flags(CREATE_SUSPENDED); #[cfg(unix)] tree.configure(&mut command); - let mut child = command.spawn().with_context(|| format!("run {}", binary.display()))?; + let mut child = cmux_tui_process::spawn(&mut command) + .with_context(|| format!("run {}", binary.display()))?; #[cfg(unix)] if let Err(error) = tree.bind(child.id()) { tree.terminate_until(deadline); @@ -1420,6 +1421,43 @@ mod tests { assert!(started.elapsed() < Duration::from_secs(2)); } + #[cfg(target_os = "macos")] + #[test] + fn hermes_launch_waits_for_process_creation_barrier() { + let barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); + let (completed_sender, completed_receiver) = std::sync::mpsc::sync_channel(1); + let worker = std::thread::spawn(move || { + started_sender.send(()).unwrap(); + completed_sender + .send(run_hermes_command_with_timeout( + Path::new("/usr/bin/true"), + &[], + Duration::from_secs(5), + )) + .unwrap(); + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("Hermes launch worker did not start"); + + assert!( + matches!( + completed_receiver.recv_timeout(Duration::from_millis(250)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), + "Hermes launch bypassed the process creation barrier" + ); + drop(barrier); + + let output = completed_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Hermes launch did not resume after the process creation barrier opened") + .unwrap(); + assert!(output.status.success()); + worker.join().unwrap(); + } + #[cfg(not(unix))] #[test] fn public_hook_operations_are_rejected_on_unsupported_platforms() { diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index ec5cdbeb90c2..de113a1a3051 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -2011,6 +2011,10 @@ impl OrderedSession { self.inner.tree() } + fn shutdown_requested(&self) -> bool { + self.inner.shutdown_requested() + } + fn agents(&self) -> Vec { self.inner.agents() } @@ -2195,6 +2199,7 @@ impl OrderedSession { self.inner.begin_shutdown(); } + #[cfg(test)] fn daemon_shutdown_requested(&self) -> bool { self.inner.daemon_shutdown_requested() } @@ -7855,6 +7860,12 @@ fn run_with_machine_updates_inner( ensure_initial_for_machine_ui(&session, initial_size, machine_ui.as_ref()), machine_ui.as_ref(), )?; + #[cfg(debug_assertions)] + if initial_workspace_error.is_none() + && let Some(signal) = std::env::var_os("CMUX_TUI_TEST_INITIAL_WORKSPACE_READY_SIGNAL") + { + std::fs::write(signal, b"1")?; + } let encoder = KeyEncoder::new()?; let (tx, rx) = sync_channel::(APP_EVENT_CAPACITY); let owner_reload_worker = @@ -8604,6 +8615,7 @@ impl App { } } + #[cfg(test)] fn owner_shutdown_requested(&self) -> bool { self.owner_mux.as_ref().is_some_and(|mux| mux.daemon_shutdown_requested()) } @@ -9189,11 +9201,7 @@ impl App { let mut replay_ready = !self.deferred_input.is_empty() || self.pending_pointer_motion.is_some(); - while !self.quit - && !crate::shutdown_requested() - && !self.session.daemon_shutdown_requested() - && !self.owner_shutdown_requested() - { + while !self.quit && !self.shutdown_requested() { if replay_ready { let replay = self.replay_deferred_input_batch()?; let action = terminal_paints.render_immediately(replay.action, Instant::now()); @@ -9331,6 +9339,15 @@ impl App { Ok(()) } + fn shutdown_requested(&self) -> bool { + crate::shutdown_requested() + || self.session.shutdown_requested() + || self + .owner_mux + .as_ref() + .is_some_and(|mux| mux.shutdown_requested() || mux.daemon_shutdown_requested()) + } + fn restart_machine_updates(&mut self) -> anyhow::Result<()> { let Some(worker) = self.machine_action_worker.as_ref() else { return Ok(()); @@ -13369,6 +13386,7 @@ impl App { } => { if self .pending_pointer_motion + .as_ref() .is_some_and(|pointer| pointer.destination == Some(surface)) { self.pending_pointer_motion = None; @@ -25742,7 +25760,7 @@ mod tests { assert!(app.render_states.contains_key(&surface)); assert!(app.selection.is_some_and(|selection| selection.surface == surface)); assert!(app.status_message.is_none()); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -26402,7 +26420,7 @@ mod tests { 2, "a click must use the visible editor column, not the cropped logical column" ); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -26454,7 +26472,7 @@ mod tests { "the stall suffix must start after the label's display cells" ); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -29500,7 +29518,7 @@ mod tests { "every visible cell in the tab hit rect must belong to the label" ); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -31672,7 +31690,7 @@ mod tests { assert!(!app.session.has_pending_mutations()); assert_eq!(app.deferred_input.len(), 1); assert!(events.try_recv().is_err()); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -39387,6 +39405,27 @@ mod tests { worker.stop_and_join(); } + #[test] + fn owner_mux_owns_shutdown_after_the_rendered_session_changes() { + let rendered_mux = Mux::new("rendered", SurfaceOptions::default()); + let owner_mux = Mux::new("host", SurfaceOptions::default()); + let mut app = test_app(Session::Local(rendered_mux)); + app.owner_mux = Some(owner_mux.clone()); + assert!(!app.shutdown_requested()); + + owner_mux.request_shutdown(); + + assert!(app.shutdown_requested()); + + let daemon_owner_mux = Mux::new("daemon-host", SurfaceOptions::default()); + app.owner_mux = Some(daemon_owner_mux.clone()); + assert!(!app.shutdown_requested()); + + daemon_owner_mux.request_daemon_shutdown(); + + assert!(app.shutdown_requested()); + } + #[test] fn presented_local_owner_applies_one_reload_for_both_event_paths() { let owner = Mux::new("owner-reload-deduplication", SurfaceOptions::default()); @@ -39844,7 +39883,7 @@ mod tests { command: Some(vec![ "/bin/sh".to_string(), "-c".to_string(), - "sleep 30".to_string(), + "sleep 300".to_string(), ]), cwd: cwd.map(|path| path.to_string_lossy().into_owned()), ..Default::default() diff --git a/cmux-tui/crates/cmux-tui/src/cli.rs b/cmux-tui/crates/cmux-tui/src/cli.rs index 4c6fa72d58fd..8eae674a163b 100644 --- a/cmux-tui/crates/cmux-tui/src/cli.rs +++ b/cmux-tui/crates/cmux-tui/src/cli.rs @@ -102,26 +102,28 @@ pub fn run(args: &[String], startup_usage: &str) -> i32 { } CommandPlan::RawCommand(command) => raw::run(global, command), }, - Err(failure) => { - let message = if matches!(failure.output, OutputMode::Quiet | OutputMode::Human) { - format!("cmux: {}", failure.error) - } else { - failure.error.to_string() - }; - wire::print_local_error( - &serde_json::json!({ - "code":"usage.invalid", - "message":message, - "details":{}, - "retryable":false, - }), - failure.output, - 2, - ) - } + Err(failure) => print_usage_error(failure.error, failure.output), } } +fn print_usage_error(error: UsageError, output: OutputMode) -> i32 { + let message = if matches!(output, OutputMode::Quiet | OutputMode::Human) { + format!("cmux: {error}") + } else { + error.to_string() + }; + wire::print_local_error( + &serde_json::json!({ + "code":"usage.invalid", + "message":message, + "details":{}, + "retryable":false, + }), + output, + 2, + ) +} + fn parse(args: &[String]) -> Result { let (global, command_args) = parse_globals(args).map_err(|(error, output)| ParseFailure { error, output })?; @@ -379,6 +381,7 @@ PROCESS HELP cmux attach --help cmux relay --help cmux machine-agent --help + cmux server status|stop RESOURCE SCOPES "; @@ -600,6 +603,30 @@ USAGE escape for the legacy control protocol and provides no compatibility promise. "; +fn resolve_server_socket(global: &GlobalArgs) -> PathBuf { + resolve_server_socket_with(global, |name| std::env::var_os(name).map(PathBuf::from)) +} + +fn resolve_server_socket_with( + global: &GlobalArgs, + ambient_socket: impl Fn(&str) -> Option, +) -> PathBuf { + if let Some(path) = &global.socket { + return path.clone(); + } + if let Some(session) = global.session.as_deref() { + return cmux_tui_core::server::default_socket_path(session); + } + for name in ["CMUX_TUI_SOCKET", "CMUX_MUX_SOCKET"] { + if let Some(path) = ambient_socket(name) + && !path.as_os_str().is_empty() + { + return path; + } + } + cmux_tui_core::server::default_socket_path("main") +} + #[cfg(test)] mod tests { use super::*; @@ -636,6 +663,18 @@ mod tests { ); } + #[test] + fn explicit_session_overrides_ambient_server_socket() { + let global = + GlobalArgs { session: Some("explicit-session".into()), ..GlobalArgs::default() }; + let ambient = PathBuf::from("/tmp/cmux-ambient-session.sock"); + + assert_eq!( + resolve_server_socket_with(&global, |_| Some(ambient.clone())), + cmux_tui_core::server::default_socket_path("explicit-session") + ); + } + #[test] fn server_lifecycle_routing_flags_follow_action() { let ParsedCommand::Command { global, plan: CommandPlan::Server(plan) } = @@ -700,6 +739,7 @@ mod tests { fn startup_help_is_explicitly_discoverable() { let help = root_help(&crate::localization::catalog_for_locale("en_US.UTF-8").local_server); assert!(help.contains("cmux help start")); + assert!(help.contains("cmux server status|stop")); assert!(help.starts_with("cmux - ")); assert!(!help.contains("cmux-tui")); assert!(matches!( diff --git a/cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs b/cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs index ac3cf67edecb..ee35f4e5ea59 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs @@ -2,6 +2,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::time::{Duration, Instant}; use cmux_tui_core::platform::transport; +use cmux_tui_core::server::PROTOCOL_VERSION; use serde_json::{Value, json}; use super::{GlobalArgs, OutputMode}; @@ -119,14 +120,6 @@ pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { 3, ); }; - let Some(generation) = identity["generation"].as_str().filter(|value| !value.is_empty()) else { - return local_error( - "server.invalid_identity", - crate::localization::catalog().local_server.invalid_identity, - global.output, - 3, - ); - }; if expected_session.as_deref().is_some_and(|expected| actual_session != expected) { return local_error( "server.different_session", @@ -135,6 +128,38 @@ pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { 1, ); } + let generation = identity["generation"].as_str().filter(|value| !value.is_empty()); + if generation.is_none() + && identity["protocol"].as_u64().unwrap_or_default() < u64::from(PROTOCOL_VERSION) + && let ServerAction::Stop { force } = &plan.action + { + if *force { + return local_error( + "server.force_unsupported", + crate::localization::catalog().local_server.force_unsupported, + global.output, + 1, + ); + } + let result = + crate::server_lifecycle::ServerLifecycle::from_connected(socket, &identity, connection) + .and_then(crate::server_lifecycle::ServerLifecycle::stop_legacy); + return match result { + Ok(()) => print_stop_success(global.output, actual_session, pid, None), + Err(error) => { + let message = error.to_string(); + local_error("server.stop_failed", &message, global.output, 1) + } + }; + } + let Some(generation) = generation else { + return local_error( + "server.invalid_identity", + crate::localization::catalog().local_server.invalid_identity, + global.output, + 3, + ); + }; match plan.action { ServerAction::Status => print_success( @@ -240,21 +265,30 @@ pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { if let Err(message) = wait_for_close(&mut connection, deadline) { return local_error("server.stop_incomplete", &message, global.output, 3); } - print_success( - json!({ - "status":"stopped", - "accepted":true, - "session":actual_session, - "pid":pid, - "generation":generation, - "message":crate::localization::catalog().local_server.stopped, - }), - global.output, - ) + print_stop_success(global.output, actual_session, pid, Some(generation)) } } } +fn print_stop_success( + output: OutputMode, + session: &str, + pid: u32, + generation: Option<&str>, +) -> i32 { + print_success( + json!({ + "status":"stopped", + "accepted":true, + "session":session, + "pid":pid, + "generation":generation, + "message":crate::localization::catalog().local_server.stopped, + }), + output, + ) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ExchangeError { Transport, @@ -438,6 +472,14 @@ mod tests { Ok(()) } + fn read_timeout(&self) -> io::Result> { + Ok(None) + } + + fn write_timeout(&self) -> io::Result> { + Ok(None) + } + fn shutdown(&self, _: Shutdown) -> io::Result<()> { Ok(()) } @@ -482,6 +524,14 @@ mod tests { Ok(()) } + fn read_timeout(&self) -> io::Result> { + Ok(*self.read_timeout.lock().unwrap()) + } + + fn write_timeout(&self) -> io::Result> { + Ok(*self.write_timeout.lock().unwrap()) + } + fn shutdown(&self, _: Shutdown) -> io::Result<()> { Ok(()) } @@ -518,6 +568,14 @@ mod tests { Ok(()) } + fn read_timeout(&self) -> io::Result> { + Ok(None) + } + + fn write_timeout(&self) -> io::Result> { + Ok(None) + } + fn shutdown(&self, _: Shutdown) -> io::Result<()> { Ok(()) } diff --git a/cmux-tui/crates/cmux-tui/src/cli/raw.rs b/cmux-tui/crates/cmux-tui/src/cli/raw.rs index 2c418b1dd5c5..2f867d643fb7 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/raw.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/raw.rs @@ -4,7 +4,6 @@ //! internal fields and receives no public compatibility guarantees. use std::io::{self, BufRead, BufReader, Read, Write}; -use std::path::PathBuf; use std::time::Duration; use cmux_tui_core::platform::transport; @@ -31,7 +30,7 @@ pub(super) fn run(global: GlobalArgs, plan: RawCommandPlan) -> i32 { return 2; } }; - let socket = resolve_socket(&global); + let socket = super::resolve_server_socket(&global); let stream = match transport::connect(&socket) { Ok(stream) => stream, Err(error) => { @@ -132,20 +131,6 @@ fn read_line_limited( .map_err(|error| format!("protocol error: raw response is not UTF-8: {error}")) } -fn resolve_socket(global: &GlobalArgs) -> PathBuf { - if let Some(path) = &global.socket { - return path.clone(); - } - for name in ["CMUX_TUI_SOCKET", "CMUX_MUX_SOCKET"] { - if let Some(path) = std::env::var_os(name) - && !path.is_empty() - { - return PathBuf::from(path); - } - } - cmux_tui_core::server::default_socket_path(global.session.as_deref().unwrap_or("main")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/cmux-tui/crates/cmux-tui/src/cli/wire.rs b/cmux-tui/crates/cmux-tui/src/cli/wire.rs index cab817b73432..d712f6223299 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/wire.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/wire.rs @@ -57,7 +57,7 @@ pub(super) fn run(global: GlobalArgs, mut plan: RequestPlan) -> i32 { let request_id = request["id"].as_str().expect("locally built request IDs are strings").to_string(); - let socket = resolve_socket(&global); + let socket = super::resolve_server_socket(&global); let stream = match transport::connect(&socket) { Ok(stream) => stream, Err(error) => { diff --git a/cmux-tui/crates/cmux-tui/src/config.rs b/cmux-tui/crates/cmux-tui/src/config.rs index 38987685de24..de183b36e463 100644 --- a/cmux-tui/crates/cmux-tui/src/config.rs +++ b/cmux-tui/crates/cmux-tui/src/config.rs @@ -3272,7 +3272,7 @@ fn ghostty_defaults_from_helper_command( mut command: Command, parent_deadline: Duration, ) -> GhosttyHelperDefaults { - let Ok(mut child) = command.spawn() else { + let Ok(mut child) = cmux_tui_process::spawn(&mut command) else { return GhosttyHelperDefaults::Unavailable; }; let Some(stdout) = child.stdout.take() else { @@ -3389,7 +3389,7 @@ fn ghostty_helper_process_table_snapshot() -> Option { .stdout(Stdio::piped()) .stderr(Stdio::null()) .process_group(0); - let Ok(mut child) = command.spawn() else { + let Ok(mut child) = cmux_tui_process::spawn(&mut command) else { return None; }; let Some(stdout) = child.stdout.take() else { @@ -4729,7 +4729,7 @@ mod tests { assert_eq!(colors.selection_fg, Some(Rgb { r: 0xfe, g: 0xfe, b: 0xfe })); mux.close_surface(surface.id).unwrap(); - mux.shutdown(); + mux.shutdown().unwrap(); server::cleanup(&socket); } @@ -5588,6 +5588,74 @@ mod tests { assert!(!stdout.contains("CMUX_PROVIDER_WORKSPACE_AUTHORITY="), "{stdout}"); } + #[cfg(target_os = "macos")] + #[test] + fn ghostty_config_helper_waits_for_the_process_creation_barrier() { + let barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (started_sender, started_receiver) = mpsc::channel(); + let (result_sender, result_receiver) = mpsc::channel(); + let helper = std::thread::spawn(move || { + let mut command = Command::new("/usr/bin/printf"); + command + .arg("foreground=#010203\nbackground=#040506\n") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + started_sender.send(()).unwrap(); + let result = ghostty_defaults_from_helper_command(command, Duration::from_secs(5)); + result_sender.send(result).unwrap(); + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("config helper worker did not reach process creation"); + + assert!( + matches!( + result_receiver.recv_timeout(Duration::from_millis(250)), + Err(mpsc::RecvTimeoutError::Timeout) + ), + "Ghostty config helper spawned while descriptor creation held the shared barrier" + ); + drop(barrier); + let result = result_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("config helper did not resume after the process barrier was released"); + helper.join().unwrap(); + + assert!(matches!(result, GhosttyHelperDefaults::Resolved(_))); + } + + #[cfg(target_os = "macos")] + #[test] + fn ghostty_process_scan_waits_for_the_process_creation_barrier() { + let barrier = cmux_tui_process::ProcessCreationGuard::acquire(); + let (started_sender, started_receiver) = mpsc::channel(); + let (result_sender, result_receiver) = mpsc::channel(); + let helper = std::thread::spawn(move || { + started_sender.send(()).unwrap(); + let result = ghostty_helper_process_table_snapshot(); + result_sender.send(result).unwrap(); + }); + let started = started_receiver.recv_timeout(Duration::from_secs(1)).is_ok(); + let early = result_receiver.recv_timeout(Duration::from_millis(250)); + let waited = matches!(&early, Err(mpsc::RecvTimeoutError::Timeout)); + + drop(barrier); + let result = match early { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => result_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("process scan did not resume after the process barrier was released"), + Err(mpsc::RecvTimeoutError::Disconnected) => None, + }; + let helper_result = helper.join(); + + assert!(started, "process scan worker did not reach process creation"); + assert!(helper_result.is_ok(), "process scan worker panicked"); + assert!(waited, "Ghostty process scan spawned while descriptor setup held the barrier"); + assert!(result.is_some(), "process scan did not return a process table"); + } + #[cfg(unix)] fn wait_for_helper_ready_pid( stdout: std::process::ChildStdout, diff --git a/cmux-tui/crates/cmux-tui/src/localization.rs b/cmux-tui/crates/cmux-tui/src/localization.rs index 4f2c528ecfd3..7af9773cfbd9 100644 --- a/cmux-tui/crates/cmux-tui/src/localization.rs +++ b/cmux-tui/crates/cmux-tui/src/localization.rs @@ -23,6 +23,62 @@ pub(crate) struct ForeignViewportMessages { pub terminal_grid: &'static str, } +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ServerMessages { + pub help: &'static str, + pub machine_scope_unsupported: &'static str, + pub incompatible_local_server: &'static str, + pub server_label: &'static str, + pub client_label: &'static str, + pub protocol_label: &'static str, + pub status_label: &'static str, + pub reason_label: &'static str, + pub reason_separator: &'static str, + pub compatible: &'static str, + pub incompatible: &'static str, + pub reason_version: &'static str, + pub reason_source: &'static str, + pub reason_terminal_engine: &'static str, + pub reason_protocol: &'static str, + pub stop_to_use: &'static str, + pub stopping_exits_panes: &'static str, + pub restart_before_command: &'static str, + pub restart_between_commands: &'static str, + pub restart_after_command: &'static str, + pub stopped: &'static str, + pub missing_action: &'static str, + pub unknown_action: &'static str, + pub connect_failed: &'static str, + pub endpoint_invalid: &'static str, + pub identity_failed: &'static str, + pub transport_failed: &'static str, + pub response_closed: &'static str, + pub response_invalid: &'static str, + #[cfg(unix)] + pub legacy_cleanup_failed: &'static str, + #[cfg(unix)] + pub legacy_list_failed: &'static str, + #[cfg(unix)] + pub legacy_close_failed: &'static str, + #[cfg(unix)] + pub legacy_too_many_surfaces: &'static str, + #[cfg(unix)] + pub legacy_peer_unavailable: &'static str, + #[cfg(unix)] + pub legacy_peer_mismatch: &'static str, + #[cfg(unix)] + pub legacy_signal_failed: &'static str, + pub shutdown_failed: &'static str, + pub shutdown_cleanup_incomplete: &'static str, + pub shutdown_unsupported: &'static str, + pub process_cleanup_unsupported: &'static str, + pub shutdown_timed_out: &'static str, + pub cleanup_label: &'static str, + pub cleanup_pending_label: &'static str, + pub cleanup_retrying: &'static str, + pub cleanup_degraded: &'static str, +} + #[derive(Debug, PartialEq, Eq)] pub(crate) struct GraphicsMessages { pub output_failed: &'static str, @@ -1004,6 +1060,7 @@ pub(crate) struct Catalog { pub local_server: LocalServerMessages, pub pairing: PairingMessages, pub foreign_viewport: ForeignViewportMessages, + pub server: ServerMessages, pub graphics: GraphicsMessages, pub terminal: TerminalMessages, pub session: SessionMessages, @@ -1091,6 +1148,60 @@ static ENGLISH: Catalog = Catalog { approve: "[ Approve enter ]", }, foreign_viewport: ForeignViewportMessages { terminal_grid: "terminal grid" }, + server: ServerMessages { + help: "USAGE\n cmux server status\n cmux server stop\n\nThese local lifecycle commands remain available when the running server has a\ndifferent release identity.\n", + machine_scope_unsupported: "server lifecycle commands cannot use --machine", + incompatible_local_server: "This session is running a different cmux-tui release.", + server_label: "server", + client_label: "client", + protocol_label: "protocol", + status_label: "status", + reason_label: "reason", + reason_separator: ", ", + compatible: "compatible", + incompatible: "incompatible", + reason_version: "distribution version differs", + reason_source: "source build differs", + reason_terminal_engine: "terminal engine build differs", + reason_protocol: "protocol differs", + stop_to_use: "Stop the old server to use this cmux-tui version.", + stopping_exits_panes: "Stopping exits pane processes.", + restart_before_command: "Run `", + restart_between_commands: "`, then rerun the previous command using `", + restart_after_command: "`.", + stopped: "Stopped the cmux-tui server.", + missing_action: "server needs an action: status or stop", + unknown_action: "unknown server action", + connect_failed: "Could not connect to the cmux-tui server.", + endpoint_invalid: "The socket is not a cmux-tui server.", + identity_failed: "The cmux-tui server did not return its identity.", + transport_failed: "Communication with the cmux-tui server failed.", + response_closed: "The cmux-tui server closed the connection before responding.", + response_invalid: "The cmux-tui server returned an invalid response.", + #[cfg(unix)] + legacy_cleanup_failed: "could not close pane processes before stopping the older server", + #[cfg(unix)] + legacy_list_failed: "The older server could not list its pane processes.", + #[cfg(unix)] + legacy_close_failed: "The older server could not close a pane process.", + #[cfg(unix)] + legacy_too_many_surfaces: "The older server reported too many pane processes.", + #[cfg(unix)] + legacy_peer_unavailable: "The older server process could not be verified.", + #[cfg(unix)] + legacy_peer_mismatch: "The older server's reported process does not own its socket.", + #[cfg(unix)] + legacy_signal_failed: "The verified older server process could not be stopped.", + shutdown_failed: "the server could not stop cleanly", + shutdown_cleanup_incomplete: "the server could not finish cleanup before the shutdown deadline", + shutdown_unsupported: "this server cannot be stopped by this client", + process_cleanup_unsupported: "this system does not support safe terminal process cleanup", + shutdown_timed_out: "the server did not stop within 10 seconds", + cleanup_label: "cleanup", + cleanup_pending_label: "pending cleanup tasks", + cleanup_retrying: "retrying", + cleanup_degraded: "run server stop again", + }, graphics: GraphicsMessages { output_failed: "Terminal graphics output failed; restoring the terminal", parser_recovery_failed: "Terminal graphics output could not reset the terminal parser; restoring the terminal", @@ -1681,6 +1792,60 @@ static JAPANESE: Catalog = Catalog { approve: "[ 承認 enter ]", }, foreign_viewport: ForeignViewportMessages { terminal_grid: "端末グリッド" }, + server: ServerMessages { + help: "使用方法\n cmux server status\n cmux server stop\n\n実行中のサーバーが別のリリースでも、これらのローカルライフサイクル操作を利用できます。\n", + machine_scope_unsupported: "server のライフサイクル操作では --machine を使用できません", + incompatible_local_server: "このセッションは別の cmux-tui リリースで実行されています。", + server_label: "サーバー", + client_label: "クライアント", + protocol_label: "プロトコル", + status_label: "状態", + reason_label: "理由", + reason_separator: "、", + compatible: "互換", + incompatible: "非互換", + reason_version: "配布バージョンが異なります", + reason_source: "ソースビルドが異なります", + reason_terminal_engine: "ターミナルエンジンのビルドが異なります", + reason_protocol: "プロトコルが異なります", + stop_to_use: "この cmux-tui バージョンを使うには、古いサーバーを停止してください。", + stopping_exits_panes: "停止するとペインのプロセスが終了します。", + restart_before_command: "`", + restart_between_commands: "` を実行してから、以前のコマンドを `", + restart_after_command: "` で再実行してください。", + stopped: "cmux-tui サーバーを停止しました。", + missing_action: "server には status または stop の操作が必要です", + unknown_action: "不明な server 操作", + connect_failed: "cmux-tui サーバーに接続できませんでした。", + endpoint_invalid: "このソケットは cmux-tui サーバーではありません。", + identity_failed: "cmux-tui サーバーから識別情報を取得できませんでした。", + transport_failed: "cmux-tui サーバーとの通信に失敗しました。", + response_closed: "cmux-tui サーバーが応答前に接続を終了しました。", + response_invalid: "cmux-tui サーバーから無効な応答が返されました。", + #[cfg(unix)] + legacy_cleanup_failed: "古いサーバーを停止する前にペインのプロセスを終了できませんでした", + #[cfg(unix)] + legacy_list_failed: "古いサーバーからペインのプロセス一覧を取得できませんでした。", + #[cfg(unix)] + legacy_close_failed: "古いサーバーでペインのプロセスを終了できませんでした。", + #[cfg(unix)] + legacy_too_many_surfaces: "古いサーバーから報告されたペインのプロセスが多すぎます。", + #[cfg(unix)] + legacy_peer_unavailable: "古いサーバーのプロセスを検証できませんでした。", + #[cfg(unix)] + legacy_peer_mismatch: "古いサーバーが報告したプロセスはソケットを所有していません。", + #[cfg(unix)] + legacy_signal_failed: "検証済みの古いサーバープロセスを停止できませんでした。", + shutdown_failed: "サーバーを正常に停止できませんでした", + shutdown_cleanup_incomplete: "シャットダウン期限までにクリーンアップを完了できませんでした", + shutdown_unsupported: "このクライアントからこのサーバーを停止できません", + process_cleanup_unsupported: "このシステムではターミナルプロセスを安全に終了できません", + shutdown_timed_out: "10 秒以内にサーバーが停止しませんでした", + cleanup_label: "クリーンアップ", + cleanup_pending_label: "保留中のクリーンアップ", + cleanup_retrying: "再試行中", + cleanup_degraded: "server stop をもう一度実行してください", + }, graphics: GraphicsMessages { output_failed: "ターミナル画像の出力に失敗したため、ターミナルを復元します", parser_recovery_failed: "ターミナル画像の出力後にパーサーをリセットできなかったため、ターミナルを復元します", diff --git a/cmux-tui/crates/cmux-tui/src/machine_agent/identity.rs b/cmux-tui/crates/cmux-tui/src/machine_agent/identity.rs index fb4323cd6576..32a934fbfee7 100644 --- a/cmux-tui/crates/cmux-tui/src/machine_agent/identity.rs +++ b/cmux-tui/crates/cmux-tui/src/machine_agent/identity.rs @@ -1,11 +1,11 @@ +use std::ffi::CString; use std::fmt; use std::fs::{self, DirBuilder, File, OpenOptions}; use std::io::{self, Read, Write}; use std::os::fd::AsRawFd; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; -use std::thread; -use std::time::Duration; use cmux_tui_machine_agent_protocol::{MachineSecret, OpaqueId, SessionName}; use serde::{Deserialize, Serialize}; @@ -13,7 +13,6 @@ use zeroize::Zeroize; const STATE_VERSION: u16 = 1; const MAX_STATE_BYTES: u64 = 4096; -const CONCURRENT_CREATE_RETRY_DELAY: Duration = Duration::from_millis(10); #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct MachineIdentity { @@ -56,7 +55,7 @@ pub(super) fn load_or_create(path: &Path) -> anyhow::Result { match fs::symlink_metadata(path) { Ok(_) => { remove_orphaned_temporary_links(path)?; - load_with_link_retry(path) + load(path) } Err(error) if error.kind() == io::ErrorKind::NotFound => create(path), Err(error) => Err(error.into()), @@ -127,18 +126,6 @@ fn load(path: &Path) -> anyhow::Result { Ok(MachineIdentity { machine_id: stored.machine_id, secret: stored.secret }) } -fn load_with_link_retry(path: &Path) -> anyhow::Result { - match load(path) { - Err(error) - if error.downcast_ref::().is_some_and(|error| error.0 == 2) => - { - thread::sleep(CONCURRENT_CREATE_RETRY_DELAY); - load(path) - } - result => result, - } -} - fn create(path: &Path) -> anyhow::Result { let parent = state_parent(path)?; let identity = MachineIdentity { @@ -163,16 +150,11 @@ fn create(path: &Path) -> anyhow::Result { verify_private_file(&file, "temporary machine-agent state")?; file.write_all(&encoded)?; file.sync_all()?; - match fs::hard_link(&temporary, path) { + match atomic_rename_noreplace(&temporary, path) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(false), Err(error) => return Err(error.into()), } - match fs::remove_file(&temporary) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - } sync_directory(parent)?; Ok(true) })(); @@ -187,10 +169,48 @@ fn create(path: &Path) -> anyhow::Result { } Ok(loaded) } else { - load_with_link_retry(path) + load(path) } } +fn atomic_rename_noreplace(from: &Path, to: &Path) -> io::Result<()> { + let from = CString::new(from.as_os_str().as_bytes()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "source path contains a NUL byte") + })?; + let to = CString::new(to.as_os_str().as_bytes()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "target path contains a NUL byte") + })?; + #[cfg(target_vendor = "apple")] + let result = unsafe { + libc::renameatx_np( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_EXCL, + ) + }; + #[cfg(target_os = "linux")] + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + #[cfg(not(any(target_vendor = "apple", target_os = "linux")))] + let result = { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "atomic no-replace rename is unavailable on this platform", + )); + }; + if result == 0 { Ok(()) } else { Err(io::Error::last_os_error()) } +} + fn open_no_follow(path: &Path, write: bool) -> io::Result { OpenOptions::new() .read(!write) @@ -407,18 +427,21 @@ mod tests { } #[test] - fn identity_retries_the_live_two_link_creation_window_once() { - let state = test_state("live-creation-link"); - let identity = load_or_create(&state.path).unwrap(); - let temporary = state.directory.join(".identity.json.live.tmp"); - fs::hard_link(&state.path, &temporary).unwrap(); - let remover = thread::spawn(move || { - thread::sleep(Duration::from_millis(1)); - fs::remove_file(temporary).unwrap(); - }); - - assert_eq!(load_with_link_retry(&state.path).unwrap(), identity); - remover.join().unwrap(); + fn identity_creation_loser_preserves_atomic_winner_with_one_link() { + let state = test_state("concurrent-creation-loser"); + let winner = create(&state.path).unwrap(); + let loser = create(&state.path).unwrap(); + + assert_eq!(loser, winner); + assert_eq!(load(&state.path).unwrap(), winner); + assert_eq!(fs::metadata(&state.path).unwrap().nlink(), 1); + let temporary_prefix = ".identity.json."; + let temporary_suffix = ".tmp"; + assert!(!fs::read_dir(&state.directory).unwrap().any(|entry| { + let name = entry.unwrap().file_name(); + let name = name.to_string_lossy(); + name.starts_with(temporary_prefix) && name.ends_with(temporary_suffix) + })); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/machine_agent/transport.rs b/cmux-tui/crates/cmux-tui/src/machine_agent/transport.rs index 930f92fb57b7..c74f8f8e7a93 100644 --- a/cmux-tui/crates/cmux-tui/src/machine_agent/transport.rs +++ b/cmux-tui/crates/cmux-tui/src/machine_agent/transport.rs @@ -101,12 +101,13 @@ impl SshCloudConnector { impl CloudConnector for SshCloudConnector { fn connect(&self) -> io::Result { - let mut child = Command::new("ssh") + let mut command = Command::new("ssh"); + command .args(self.command_args()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn()?; + .stderr(Stdio::inherit()); + let mut child = cmux_tui_process::spawn(&mut command)?; let writer = child.stdin.take().ok_or_else(|| io::Error::other("OpenSSH did not expose stdin"))?; let reader = diff --git a/cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs b/cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs index e5f947500dfa..fe2d451787f7 100644 --- a/cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs +++ b/cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs @@ -4688,9 +4688,15 @@ mod tests { let request: Value = serde_json::from_str(&line).unwrap(); assert_eq!(request["cmd"], expected_command); let data = if expected_command == "identify" { + let release = cmux_tui_core::release::ReleaseIdentity::current( + cmux_tui_core::server::PROTOCOL_VERSION, + ); json!({ "app": "cmux-tui", - "protocol": cmux_tui_core::server::PROTOCOL_VERSION, + "version": release.version, + "build_commit": release.build_commit, + "ghostty_commit": release.ghostty_commit, + "protocol": release.protocol, "capabilities": ["browser-pointer-frame-guard-v1"], }) } else { diff --git a/cmux-tui/crates/cmux-tui/src/machine_provider_transport.rs b/cmux-tui/crates/cmux-tui/src/machine_provider_transport.rs index 0dd5e7c8ab82..933c84d04768 100644 --- a/cmux-tui/crates/cmux-tui/src/machine_provider_transport.rs +++ b/cmux-tui/crates/cmux-tui/src/machine_provider_transport.rs @@ -208,11 +208,13 @@ struct UnixMachineStreamConnector { impl MachineStreamConnector for UnixMachineStreamConnector { fn open(&self) -> io::Result { - let writer = UnixStream::connect(&self.socket_path)?; + let writer = cmux_tui_process::unix::connect_stream(&self.socket_path)?; writer.set_write_timeout(Some(PROVIDER_WRITE_TIMEOUT))?; - let reader = writer.try_clone()?; - let cleanup = - Arc::new(UnixCleanup { stream: writer.try_clone()?, closed: AtomicBool::new(false) }); + let reader = cmux_tui_process::unix::clone_stream(&writer)?; + let cleanup = Arc::new(UnixCleanup { + stream: cmux_tui_process::unix::clone_stream(&writer)?, + closed: AtomicBool::new(false), + }); Ok(ProviderIo::new(reader, writer, ProviderIoGuard::new(cleanup))) } } @@ -500,7 +502,7 @@ fn spawn_command( arguments: &[OsString], redactions: Arc>, ) -> io::Result { - let (stderr_cancel, stderr_cancel_worker) = UnixStream::pair()?; + let (stderr_cancel, stderr_cancel_worker) = cmux_tui_process::unix::pair_stream()?; let mut command = Command::new(program); command .args(arguments) @@ -509,7 +511,7 @@ fn spawn_command( .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - let mut child = command.spawn().map_err(|error| { + let mut child = cmux_tui_process::spawn(&mut command).map_err(|error| { io::Error::new(error.kind(), format!("failed to start machine-provider command: {error}")) })?; let stdin = child diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 2a1d0489f812..6ebf78ac1c56 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -33,6 +33,7 @@ mod provider_notice_identity; mod pty_input; #[cfg(unix)] mod remote_cli; +mod server_lifecycle; #[cfg(not(unix))] mod remote_cli { const REMOTE_COMMANDS: &[&str] = &[ @@ -112,6 +113,9 @@ static SIGNAL_WAKE_WRITER: AtomicI32 = AtomicI32::new(-1); #[cfg(unix)] const MACHINE_PROVIDER_TOKEN_ENV: &str = "CMUX_MACHINE_PROVIDER_TOKEN"; const PROVIDER_WORKSPACE_AUTHORITY_ENV: &str = "CMUX_PROVIDER_WORKSPACE_AUTHORITY"; +// Once core cleanup owns no external processes, give the interactive driver +// one final second to unwind before releasing the socket and exiting. +const SERVER_SHUTDOWN_EXIT_GRACE: std::time::Duration = std::time::Duration::from_secs(1); #[cfg(target_os = "linux")] unsafe extern "C" { @@ -766,20 +770,8 @@ fn parse_args_result(args: impl IntoIterator) -> Result String { - // Packaged builds stamp both source identities so artifact validation can - // reject a cmux binary built against a different Ghostty checkout before - // it enters an app bundle. Local builds report the crate version alone. - let commit = option_env!("CMUX_TUI_BUILD_COMMIT") - .or(option_env!("CMUX_MUX_BUILD_COMMIT")) - .filter(|commit| !commit.is_empty()); - let ghostty = option_env!("CMUX_TUI_GHOSTTY_COMMIT").filter(|commit| !commit.is_empty()); - match (commit, ghostty) { - (Some(commit), Some(ghostty)) => { - format!("{} ({commit}; ghostty {ghostty})", env!("CARGO_PKG_VERSION")) - } - (Some(commit), None) => format!("{} ({commit})", env!("CARGO_PKG_VERSION")), - (None, _) => env!("CARGO_PKG_VERSION").to_string(), - } + cmux_tui_core::release::ReleaseIdentity::current(cmux_tui_core::server::PROTOCOL_VERSION) + .version_with_build_metadata() } #[cfg(unix)] @@ -1379,6 +1371,23 @@ fn main() { } return; } + if raw_args.first().map(String::as_str) == Some("__resolve-host") { + discard_provider_secret_environment(); + if let Err(error) = cmux_tui_cdp::run_host_resolver_helper(&raw_args[1..]) { + eprintln!("cmux-tui host resolver: {error}"); + std::process::exit(1); + } + return; + } + #[cfg(unix)] + if raw_args.first().map(String::as_str) == Some("__legacy-stop-helper") { + discard_provider_secret_environment(); + if let Err(error) = server_lifecycle::run_legacy_stop_helper(&raw_args[1..]) { + eprintln!("{error}"); + std::process::exit(1); + } + return; + } if config::is_ghostty_config_helper_invocation(&raw_args) { if let Err(error) = harden_provider_secret_process() { eprintln!("cmux-tui: cannot protect machine-provider credentials: {error}"); @@ -1494,7 +1503,7 @@ fn main() { } fn run_terminal_host_process(args: &[String]) -> anyhow::Result<()> { - cmux_tui_core::terminal_host_runtime::isolate_terminal_host_process_fds()?; + cmux_tui_core::terminal_host_runtime::isolate_terminal_host_process_fds(args)?; let stdin = io::stdin(); let stdout = io::stdout(); let mut reader = stdin.lock(); @@ -1698,30 +1707,754 @@ fn socket_option(fd: std::os::fd::RawFd, option: libc::c_int) -> io::Result>, - socket_path: PathBuf, +#[derive(Default)] +struct ServerProcessOwners { + websocket: Option, + #[cfg(unix)] + remote: Option, + #[cfg(target_os = "linux")] + provider_management: Option, +} + +impl ServerProcessOwners { + fn shutdown_until(&mut self, deadline: std::time::Instant) -> ServerProcessOwnersShutdown { + #[cfg(target_os = "linux")] + if let Some(provider_management) = self.provider_management.as_mut() { + if provider_management.shutdown_until(deadline) { + drop(self.provider_management.take()); + } else { + return ServerProcessOwnersShutdown::Pending { + message: "provider management cleanup exceeded the shutdown deadline", + }; + } + } + if let Some(websocket) = self.websocket.as_mut() { + match websocket.shutdown_until(deadline) { + Ok(true) => drop(self.websocket.take()), + Ok(false) => { + return ServerProcessOwnersShutdown::Pending { + message: "WebSocket server cleanup exceeded the shutdown deadline", + }; + } + Err(error) => { + drop(self.websocket.take()); + return ServerProcessOwnersShutdown::Complete(Err( + error.context("WebSocket server shutdown failed") + )); + } + } + } + #[cfg(unix)] + if let Some(remote) = self.remote.as_mut() { + match remote.shutdown_until(deadline) { + remote_runtime::DaemonRuntimeShutdown::Complete(result) => { + self.remote.take(); + return ServerProcessOwnersShutdown::Complete(result); + } + remote_runtime::DaemonRuntimeShutdown::Pending => { + return ServerProcessOwnersShutdown::Pending { + message: "remote daemon cleanup exceeded the shutdown deadline", + }; + } + } + } + ServerProcessOwnersShutdown::Complete(Ok(())) + } +} + +enum ServerProcessOwnersShutdown { + Complete(anyhow::Result<()>), + Pending { message: &'static str }, +} + +enum ServerProcessOwnersState { + Registering(ServerProcessOwners), + Ready(ServerProcessOwners), + Running, + Complete(Option), +} + +enum ServerProcessOwnersCleanupOutcome { + Complete { error: Option }, + Incomplete { message: String }, +} + +struct ServerProcessOwnersCleanup { + state: std::sync::Mutex, + changed: std::sync::Condvar, +} + +impl ServerProcessOwnersCleanup { + fn new() -> Self { + Self { + state: std::sync::Mutex::new(ServerProcessOwnersState::Registering( + ServerProcessOwners::default(), + )), + changed: std::sync::Condvar::new(), + } + } + + fn register_websocket(&self, server: cmux_tui_core::server::WebSocketServer) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let ServerProcessOwnersState::Registering(owners) = &mut *state else { + panic!("server process owners were sealed before WebSocket registration"); + }; + assert!(owners.websocket.replace(server).is_none(), "WebSocket owner registered twice"); + } + + #[cfg(unix)] + fn register_remote(&self, runtime: remote_runtime::DaemonRuntimeHandle) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let ServerProcessOwnersState::Registering(owners) = &mut *state else { + panic!("server process owners were sealed before remote registration"); + }; + assert!(owners.remote.replace(runtime).is_none(), "remote owner registered twice"); + } + + #[cfg(target_os = "linux")] + fn register_provider_management( + &self, + server: cmux_tui_core::provider_management::ProviderManagementServer, + ) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let ServerProcessOwnersState::Registering(owners) = &mut *state else { + panic!("server process owners were sealed before provider registration"); + }; + assert!( + owners.provider_management.replace(server).is_none(), + "provider owner registered twice" + ); + } + + fn seal(&self) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if matches!(&*state, ServerProcessOwnersState::Registering(_)) { + let ServerProcessOwnersState::Registering(owners) = + std::mem::replace(&mut *state, ServerProcessOwnersState::Running) + else { + unreachable!() + }; + *state = ServerProcessOwnersState::Ready(owners); + self.changed.notify_all(); + } + } + + #[cfg(unix)] + fn remote_is_finished(&self) -> bool { + let state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + match &*state { + ServerProcessOwnersState::Registering(owners) + | ServerProcessOwnersState::Ready(owners) => { + owners.remote.as_ref().is_some_and(remote_runtime::DaemonRuntimeHandle::is_finished) + } + ServerProcessOwnersState::Running | ServerProcessOwnersState::Complete(_) => true, + } + } + + fn run_once_until(&self, deadline: std::time::Instant) -> ServerProcessOwnersCleanupOutcome { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut owners = loop { + match &*state { + ServerProcessOwnersState::Registering(_) | ServerProcessOwnersState::Running => { + let Some(remaining) = + deadline.checked_duration_since(std::time::Instant::now()) + else { + return ServerProcessOwnersCleanupOutcome::Incomplete { + message: + "server process owner registration exceeded the shutdown deadline" + .into(), + }; + }; + let (next, timeout) = self + .changed + .wait_timeout(state, remaining) + .unwrap_or_else(std::sync::PoisonError::into_inner); + state = next; + if timeout.timed_out() + && matches!( + &*state, + ServerProcessOwnersState::Registering(_) + | ServerProcessOwnersState::Running + ) + { + return ServerProcessOwnersCleanupOutcome::Incomplete { + message: + "server process owner registration exceeded the shutdown deadline" + .into(), + }; + } + } + ServerProcessOwnersState::Ready(_) => { + let ServerProcessOwnersState::Ready(owners) = + std::mem::replace(&mut *state, ServerProcessOwnersState::Running) + else { + unreachable!() + }; + break owners; + } + ServerProcessOwnersState::Complete(error) => { + return ServerProcessOwnersCleanupOutcome::Complete { error: error.clone() }; + } + } + }; + drop(state); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + owners.shutdown_until(deadline) + })); + state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let outcome = match result { + Ok(ServerProcessOwnersShutdown::Complete(result)) => { + let error = result.as_ref().err().map(|error| format!("{error:#}")); + *state = ServerProcessOwnersState::Complete(error.clone()); + ServerProcessOwnersCleanupOutcome::Complete { error } + } + Ok(ServerProcessOwnersShutdown::Pending { message }) => { + *state = ServerProcessOwnersState::Ready(owners); + ServerProcessOwnersCleanupOutcome::Incomplete { message: message.into() } + } + Err(_) => { + *state = ServerProcessOwnersState::Ready(owners); + ServerProcessOwnersCleanupOutcome::Incomplete { + message: "server process owner cleanup panicked".into(), + } + } + }; + self.changed.notify_all(); + outcome + } +} + +enum ServerShutdownCleanupState { + Pending(u64), + Running(u64), + Complete { error: Option }, + Degraded { attempt: u64, message: String, retry: cmux_tui_core::ShutdownRequestWatch }, + Abandoned, +} + +#[derive(Clone)] +enum ServerShutdownCleanupOutcome { + Complete { error: Option }, + Degraded { attempt: u64, message: String, retry: cmux_tui_core::ShutdownRequestWatch }, + Abandoned, +} + +#[cfg(test)] +type ServerShutdownAttempt = Arc anyhow::Result<()> + Send + Sync + 'static>; + +#[cfg(test)] +thread_local! { + static SERVER_SHUTDOWN_ATTEMPT_FOR_RUN: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn set_server_shutdown_attempt_for_run(attempt: Option) { + SERVER_SHUTDOWN_ATTEMPT_FOR_RUN.with(|slot| { + *slot.borrow_mut() = attempt; + }); +} + +struct ServerShutdownCleanup { + mux: Arc, + process_owners: Arc, + state: std::sync::Mutex, + changed: std::sync::Condvar, + #[cfg(test)] + shutdown_attempt: std::sync::Mutex>, + #[cfg(test)] + process_owner_timeout: std::sync::Mutex>, +} + +impl ServerShutdownCleanup { + #[cfg(test)] + fn new(mux: Arc) -> Self { + let process_owners = Arc::new(ServerProcessOwnersCleanup::new()); + process_owners.seal(); + Self::new_with_process_owners(mux, process_owners) + } + + fn new_with_process_owners( + mux: Arc, + process_owners: Arc, + ) -> Self { + Self { + mux, + process_owners, + state: std::sync::Mutex::new(ServerShutdownCleanupState::Pending(0)), + changed: std::sync::Condvar::new(), + #[cfg(test)] + shutdown_attempt: std::sync::Mutex::new( + SERVER_SHUTDOWN_ATTEMPT_FOR_RUN.with(|slot| slot.borrow().clone()), + ), + #[cfg(test)] + process_owner_timeout: std::sync::Mutex::new(None), + } + } + + fn attempt_mux_shutdown_until(&self, deadline: std::time::Instant) -> anyhow::Result<()> { + #[cfg(test)] + if let Some(attempt) = + self.shutdown_attempt.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() + { + return attempt(); + } + self.mux.shutdown_until(deadline) + } + + #[cfg(test)] + fn set_shutdown_attempt_for_test( + &self, + attempt: impl Fn() -> anyhow::Result<()> + Send + Sync + 'static, + ) { + *self.shutdown_attempt.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(Arc::new(attempt)); + } + + #[cfg(test)] + fn set_process_owner_timeout_for_test(&self, timeout: std::time::Duration) { + *self.process_owner_timeout.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(timeout); + } + + fn cleanup_deadline(&self) -> std::time::Instant { + #[cfg(test)] + if let Some(timeout) = + *self.process_owner_timeout.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + { + return std::time::Instant::now() + timeout; + } + std::time::Instant::now() + cmux_tui_core::server::SERVER_SHUTDOWN_TIMEOUT + } + + fn run_once(&self) -> ServerShutdownCleanupOutcome { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let (attempt, retry) = loop { + match &*state { + ServerShutdownCleanupState::Complete { error } => { + return ServerShutdownCleanupOutcome::Complete { error: error.clone() }; + } + ServerShutdownCleanupState::Degraded { attempt, message, retry } => { + return ServerShutdownCleanupOutcome::Degraded { + attempt: *attempt, + message: message.clone(), + retry: retry.clone(), + }; + } + ServerShutdownCleanupState::Abandoned => { + return ServerShutdownCleanupOutcome::Abandoned; + } + ServerShutdownCleanupState::Running(_) => { + state = + self.changed.wait(state).unwrap_or_else(std::sync::PoisonError::into_inner); + } + ServerShutdownCleanupState::Pending(attempt) => { + let attempt = *attempt; + let retry = self.mux.watch_next_shutdown_request(); + *state = ServerShutdownCleanupState::Running(attempt); + break (attempt, retry); + } + } + }; + drop(state); + + self.mux.schedule_server_shutdown_cleanup(); + + // Runtime owners and mux resources share one absolute deadline. If an + // owner is still live, retain it and the published socket until an + // explicit retry instead of starting a second cleanup budget. + let deadline = self.cleanup_deadline(); + let process_owner_outcome = self.process_owners.run_once_until(deadline); + let outcome = match process_owner_outcome { + ServerProcessOwnersCleanupOutcome::Incomplete { message } => { + ServerShutdownCleanupOutcome::Degraded { + attempt, + message: format!( + "{}: {message}", + localization::catalog().server.shutdown_cleanup_incomplete + ), + retry, + } + } + ServerProcessOwnersCleanupOutcome::Complete { error: process_owner_error } => { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.attempt_mux_shutdown_until(deadline) + })); + match result { + Ok(Ok(())) => { + ServerShutdownCleanupOutcome::Complete { error: process_owner_error } + } + Ok(Err(error)) => ServerShutdownCleanupOutcome::Degraded { + attempt, + message: format!( + "{}: {error:#}{}", + localization::catalog().server.shutdown_cleanup_incomplete, + process_owner_error + .as_deref() + .map(|owner| format!("; process owner cleanup: {owner}")) + .unwrap_or_default() + ), + retry, + }, + Err(_) => ServerShutdownCleanupOutcome::Degraded { + attempt, + message: format!( + "{}{}", + localization::catalog().server.shutdown_cleanup_incomplete, + process_owner_error + .as_deref() + .map(|owner| format!("; process owner cleanup: {owner}")) + .unwrap_or_default() + ), + retry, + }, + } + } + }; + match &outcome { + ServerShutdownCleanupOutcome::Complete { .. } => { + self.mux.complete_server_shutdown_cleanup(); + } + ServerShutdownCleanupOutcome::Degraded { .. } + | ServerShutdownCleanupOutcome::Abandoned => { + self.mux.degrade_server_shutdown_cleanup(); + } + } + let next_state = match &outcome { + ServerShutdownCleanupOutcome::Complete { error } => { + ServerShutdownCleanupState::Complete { error: error.clone() } + } + ServerShutdownCleanupOutcome::Degraded { attempt, message, retry } => { + ServerShutdownCleanupState::Degraded { + attempt: *attempt, + message: message.clone(), + retry: retry.clone(), + } + } + ServerShutdownCleanupOutcome::Abandoned => ServerShutdownCleanupState::Abandoned, + }; + state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!( + matches!(*state, ServerShutdownCleanupState::Running(current) if current == attempt) + ); + *state = next_state; + self.changed.notify_all(); + outcome + } + + fn run_until_complete(&self) -> anyhow::Result<()> { + match self.run_once() { + ServerShutdownCleanupOutcome::Complete { error: Some(error) } => { + Err(anyhow::anyhow!(error)) + } + ServerShutdownCleanupOutcome::Complete { error: None } => Ok(()), + ServerShutdownCleanupOutcome::Degraded { message, .. } => Err(anyhow::anyhow!(message)), + ServerShutdownCleanupOutcome::Abandoned => { + Err(anyhow::anyhow!("server shutdown cleanup was abandoned")) + } + } + } + + fn abandon(&self) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let ServerShutdownCleanupState::Degraded { retry, .. } = &*state { + retry.cancel(); + } + *state = ServerShutdownCleanupState::Abandoned; + self.changed.notify_all(); + } + + fn prepare_retry(&self, attempt: u64) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if matches!( + &*state, + ServerShutdownCleanupState::Degraded { + attempt: current, + .. + } if *current == attempt + ) { + *state = ServerShutdownCleanupState::Pending(attempt.saturating_add(1)); + self.changed.notify_all(); + } + } + + fn wait_for_complete(&self) -> anyhow::Result<()> { + let mut reported_attempt = None; + loop { + match self.run_once() { + ServerShutdownCleanupOutcome::Complete { error: Some(error) } => { + return Err(anyhow::anyhow!(error)); + } + ServerShutdownCleanupOutcome::Complete { error: None } => return Ok(()), + ServerShutdownCleanupOutcome::Degraded { attempt, message, retry } => { + if reported_attempt != Some(attempt) { + eprintln!("cmux-tui: {message}; retry with `cmux-tui server stop`"); + reported_attempt = Some(attempt); + } + if retry.wait() { + self.prepare_retry(attempt); + } else { + let state = + self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _state = self + .changed + .wait_while(state, |state| { + matches!( + state, + ServerShutdownCleanupState::Degraded { + attempt: current, + .. + } if *current == attempt + ) + }) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } + ServerShutdownCleanupOutcome::Abandoned => { + return Err(anyhow::anyhow!("server shutdown cleanup was abandoned")); + } + } + } + } + + fn is_complete(&self) -> bool { + matches!( + *self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner), + ServerShutdownCleanupState::Complete { .. } + ) + } } -impl ServedMuxCleanup { - fn new(mux: Arc, socket_path: PathBuf) -> Self { - Self { mux: Some(mux), socket_path } +struct ServerProcessShutdownGuard { + published_socket: Arc>>, + shutdown_watch: cmux_tui_core::ShutdownRequestWatch, + process_owners: Arc, + cleanup: Arc, + completion: Option>, + worker: Option>, +} + +impl ServerProcessShutdownGuard { + fn start(mux: &Arc) -> io::Result { + #[cfg(debug_assertions)] + if let Some(marker) = std::env::var_os("CMUX_TUI_TEST_WATCHDOG_START_FAILURE") { + std::fs::write(marker, b"starting")?; + if let Some(release) = std::env::var_os("CMUX_TUI_TEST_WATCHDOG_START_FAILURE_RELEASE") + { + use std::io::Read as _; + + let mut release = std::fs::File::open(release)?; + let mut signal = [0_u8; 1]; + release.read_exact(&mut signal)?; + } + return Err(io::Error::other("forced watchdog start failure")); + } + let shutdown_watch = mux.watch_shutdown_request(); + let worker_watch = shutdown_watch.clone(); + let published_socket = Arc::new(std::sync::Mutex::new(None)); + let worker_published_socket = published_socket.clone(); + let process_owners = Arc::new(ServerProcessOwnersCleanup::new()); + let cleanup = Arc::new(ServerShutdownCleanup::new_with_process_owners( + mux.clone(), + process_owners.clone(), + )); + let worker_cleanup = cleanup.clone(); + let (completion, completed) = std::sync::mpsc::channel(); + let worker = std::thread::Builder::new().name("cmux-server-exit-watchdog".into()).spawn( + move || { + if !worker_watch.wait() { + return; + } + + // A blocked frontend cannot own process cleanup. Complete the + // same bounded cleanup path that run_server joins below. + // Failed attempts keep serving the control socket and require + // another explicit stop request before retrying. + let cleanup_result = worker_cleanup.wait_for_complete(); + if matches!( + completed.recv_timeout(server_shutdown_exit_grace()), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ) { + // Cleanup already proved that no external ownership + // remains. The frontend alone is stuck, so the old + // process can now release its control socket. + cleanup_published_socket(&worker_published_socket); + if let Err(error) = cleanup_result { + eprintln!("cmux-tui: server shutdown cleanup failed: {error:#}"); + std::process::exit(1); + } + std::process::exit(0); + } + }, + )?; + Ok(Self { + published_socket, + shutdown_watch, + process_owners, + cleanup, + completion: Some(completion), + worker: Some(worker), + }) + } + + fn publish_socket(&self, published: cmux_tui_core::server::PublishedSocket) -> io::Result<()> { + let mut slot = + self.published_socket.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.is_some() { + published.cleanup(); + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "server socket ownership is already published", + )); + } + *slot = Some(published); + Ok(()) + } + + fn has_published_socket(&self) -> bool { + self.published_socket.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some() + } + + fn register_websocket(&self, server: cmux_tui_core::server::WebSocketServer) { + self.process_owners.register_websocket(server); + } + + #[cfg(unix)] + fn register_remote(&self, runtime: remote_runtime::DaemonRuntimeHandle) { + self.process_owners.register_remote(runtime); + } + + #[cfg(target_os = "linux")] + fn register_provider_management( + &self, + server: cmux_tui_core::provider_management::ProviderManagementServer, + ) { + self.process_owners.register_provider_management(server); + } + + fn seal_process_owners(&self) { + self.process_owners.seal(); + } + + #[cfg(unix)] + fn remote_is_finished(&self) -> bool { + self.process_owners.remote_is_finished() + } + + fn shutdown(&self) -> anyhow::Result<()> { + self.cleanup.run_until_complete() + } + + fn wait_for_shutdown(&self) -> anyhow::Result<()> { + // `wait_for_complete` runs the first bounded cleanup attempt + // synchronously. It waits for another mux request only after that + // attempt fails, while the published socket remains available for an + // explicit `server stop` retry. + self.cleanup.wait_for_complete() + } + + fn complete(mut self) { + self.finish(); } - fn disarm(&mut self) { - self.mux = None; + fn abandon_unpublished(mut self) { + debug_assert!(!self.has_published_socket()); + self.cleanup.abandon(); + let Some(completion) = self.completion.take() else { return }; + let _ = completion.send(()); + self.shutdown_watch.cancel(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } + + fn finish(&mut self) { + if !self.cleanup.is_complete() { + return; + } + let Some(completion) = self.completion.take() else { return }; + if self.cleanup.is_complete() { + cleanup_published_socket(&self.published_socket); + } + let _ = completion.send(()); + self.shutdown_watch.cancel(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } } } -impl Drop for ServedMuxCleanup { +impl Drop for ServerProcessShutdownGuard { fn drop(&mut self) { - if let Some(mux) = self.mux.take() { - mux.shutdown(); - cmux_tui_core::server::cleanup(&self.socket_path); + self.finish(); + } +} + +fn cleanup_published_socket( + published_socket: &std::sync::Mutex>, +) { + let published = + published_socket.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take(); + if let Some(published) = published { + published.cleanup(); + } +} + +fn finish_server_process( + server_process: ServerProcessShutdownGuard, + result: anyhow::Result<()>, +) -> anyhow::Result<()> { + server_process.seal_process_owners(); + let has_published_socket = server_process.has_published_socket(); + let mut shutdown_result = if !has_published_socket || result.is_err() { + server_process.shutdown() + } else { + server_process.wait_for_shutdown() + }; + if has_published_socket && shutdown_result.is_err() && !server_process.cleanup.is_complete() { + // A retryable mux failure retains the published socket until another + // explicit stop request authorizes the next bounded attempt. + shutdown_result = server_process.wait_for_shutdown(); + } + if has_published_socket || server_process.cleanup.is_complete() { + server_process.complete(); + } else { + server_process.abandon_unpublished(); + } + let result = combine_server_and_remote_shutdown_results(result, shutdown_result); + #[cfg(debug_assertions)] + if let Some(signal) = std::env::var_os("CMUX_TUI_TEST_SERVER_EXIT_READY_SIGNAL") { + let _ = std::fs::write(signal, b"1"); + } + result +} + +fn combine_server_and_remote_shutdown_results( + server_result: anyhow::Result<()>, + remote_shutdown_result: anyhow::Result<()>, +) -> anyhow::Result<()> { + match (server_result, remote_shutdown_result) { + (result, Ok(())) => result, + (Ok(()), Err(shutdown_error)) => Err(shutdown_error), + (Err(error), Err(shutdown_error)) => { + Err(error.context(format!("remote daemon shutdown also failed: {shutdown_error:#}"))) } } } +fn server_shutdown_exit_grace() -> std::time::Duration { + #[cfg(debug_assertions)] + if let Some(milliseconds) = std::env::var("CMUX_TUI_TEST_SHUTDOWN_EXIT_GRACE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|milliseconds| (1..=5_000).contains(milliseconds)) + { + return std::time::Duration::from_millis(milliseconds); + } + SERVER_SHUTDOWN_EXIT_GRACE +} + struct LocalOwnerEventLoop { stop: Option, thread: Option>, @@ -1752,7 +2485,6 @@ impl Drop for LocalOwnerEventLoop { let _ = self.stop_and_join(); } } - fn run_server( args: Args, provider_workspace_authority: Option, @@ -1782,7 +2514,7 @@ fn run_server( .unwrap_or_else(|| cmux_tui_core::server::default_socket_path(&args.session)); if args.should_attach_existing(&ws_addr, &ws_token) && socket_path.exists() - && let Ok(remote) = RemoteSession::connect(&socket_path) + && let Some(remote) = connect_existing_local_session(&socket_path)? { return run_connected_session_client( socket_path, @@ -1793,6 +2525,9 @@ fn run_server( ); } + #[cfg(unix)] + cmux_tui_core::process_session::require_stable_process_signaling() + .map_err(|_| anyhow::anyhow!(localization::catalog().server.process_cleanup_unsupported))?; #[cfg(unix)] let (remote_relays, remote_direct_websocket, remote_workspace_http) = if args.remote { let relays = @@ -1892,10 +2627,7 @@ fn run_server( // Ghostty's config before any protocol client can create a surface. mux.seed_default_colors_if_no_durable_override(config.terminal_defaults); mux.configure_sidebar_plugin(config.sidebar.plugin.clone()); - #[cfg(target_os = "linux")] - let _provider_management = provider_management_listener - .map(|listener| cmux_tui_core::provider_management::serve(listener, mux.clone())) - .transpose()?; + let server_process = ServerProcessShutdownGuard::start(&mux)?; let owner_event_loop = background_owner_reload_completion(args.headless).map(|complete_reload| { if complete_reload { @@ -1908,13 +2640,68 @@ fn run_server( match cmux_tui_core::server::serve_paused(mux.clone(), Some(socket_path.clone())) { Ok(server) => server, Err(error) => { - mux.shutdown(); - return Err(error); + return finish_server_process(server_process, Err(error)); } }; + let published_socket = pending_server.into_published_socket(); + if let Err(error) = server_process.publish_socket(published_socket) { + return finish_server_process(server_process, Err(error.into())); + } + #[cfg(debug_assertions)] + if let Some(marker) = std::env::var_os("CMUX_TUI_TEST_LOCAL_SOCKET_PUBLISHED_MARKER") + && let Err(error) = std::fs::write(marker, b"published") + { + return finish_server_process(server_process, Err(error.into())); + } + + #[cfg(target_os = "linux")] + let provider_management = match provider_management_listener + .map(|listener| cmux_tui_core::provider_management::serve(listener, mux.clone())) + .transpose() + { + Ok(server) => server, + Err(error) => { + return finish_server_process(server_process, Err(error.into())); + } + }; + #[cfg(target_os = "linux")] + if let Some(provider_management) = provider_management { + server_process.register_provider_management(provider_management); + } + let websocket_server = match ws_addr { + Some(addr) => { + let addr = match addr.parse() { + Ok(addr) => addr, + Err(error) => { + return finish_server_process( + server_process, + Err(anyhow::anyhow!("invalid WebSocket address {addr:?}: {error}")), + ); + } + }; + match cmux_tui_core::server::serve_websocket( + mux.clone(), + addr, + ws_token, + args.ws_insecure_bind, + ) { + Ok(server) => Some(server), + Err(error) => { + return finish_server_process(server_process, Err(error)); + } + } + } + None => None, + }; + if let Some(server) = &websocket_server { + eprintln!("cmux-tui: WebSocket control at ws://{}", server.local_addr()); + } + if let Some(websocket_server) = websocket_server { + server_process.register_websocket(websocket_server); + } #[cfg(unix)] - let remote_runtime = if args.remote { + if args.remote { let runtime = match remote_runtime::start_daemon_runtime( socket_path.clone(), remote_runtime::DaemonRuntimeOptions { @@ -1934,8 +2721,7 @@ fn run_server( ) { Ok(runtime) => runtime, Err(error) => { - mux.shutdown(); - return Err(error); + return finish_server_process(server_process, Err(error)); } }; eprintln!( @@ -1947,42 +2733,26 @@ fn run_server( for route in &runtime.info().routes { eprintln!("cmux-tui: remote route {route}"); } - Some(runtime) - } else { - None - }; + server_process.register_remote(runtime); + #[cfg(debug_assertions)] + if let Some(marker) = std::env::var_os("CMUX_TUI_TEST_REMOTE_RUNTIME_STARTED_MARKER") + && let Err(error) = std::fs::write(marker, b"started") + { + return finish_server_process(server_process, Err(error.into())); + } + } + server_process.seal_process_owners(); - let websocket_server = match (|| -> anyhow::Result<_> { - Ok(match ws_addr { - Some(addr) => { - let addr = addr - .parse() - .map_err(|error| anyhow::anyhow!("invalid WebSocket address: {error}"))?; - Some(cmux_tui_core::server::serve_websocket( - mux.clone(), - addr, - ws_token, - args.ws_insecure_bind, - )?) - } - None => None, - }) - })() { - Ok(server) => server, - Err(error) => { - #[cfg(unix)] - if let Some(runtime) = remote_runtime { - let _ = runtime.shutdown(); - } - mux.shutdown(); - return Err(error); + #[cfg(debug_assertions)] + if !args.headless && std::env::var_os("CMUX_TUI_TEST_BLOCK_INTERACTIVE_DRIVER").is_some() { + // This hook models a driver that stops after the mux has completed + // shutdown-owner publication. A lifecycle request must still be able + // to close those owners and force this blocked process to exit. + mux.mark_server_lifecycle_ready(); + loop { + std::thread::park(); } - }; - if let Some(server) = &websocket_server { - eprintln!("cmux-tui: WebSocket control at ws://{}", server.local_addr()); } - let served_socket = pending_server.into_bound_path(); - let mut served_mux_cleanup = ServedMuxCleanup::new(mux.clone(), served_socket); let machine_runtime = (config.machine_sidebar.enabled || !config.machine_sidebar.create_sources.is_empty() @@ -1998,53 +2768,26 @@ fn run_server( mux.mark_server_lifecycle_ready(); #[cfg(unix)] { - run_headless(&mux, &socket_path, || { - remote_runtime - .as_ref() - .is_some_and(remote_runtime::DaemonRuntimeHandle::is_finished) - }) + run_headless(&mux, &socket_path, || server_process.remote_is_finished()) } #[cfg(not(unix))] { run_headless(&mux, &socket_path, || false) } } else if let Some(runtime) = machine_runtime { - run_machine_client(runtime, mux.clone()) + run_machine_client(runtime, mux) } else { match RemoteSession::connect(&socket_path) .context("connect the interactive client to its session server") { Ok(remote) => { - run_tui_with_owner(Session::Remote(remote), args.session, None, Some(mux.clone())) + run_tui_with_owner(Session::Remote(remote), args.session, None, Some(mux)) } Err(error) => Err(error), } }; let owner_event_result = owner_event_loop.map_or(Ok(()), LocalOwnerEventLoop::finish); - #[cfg(unix)] - let remote_shutdown = remote_runtime.map(|runtime| runtime.shutdown()).transpose(); - #[cfg(unix)] - { - let shutdown_result = finish_server_shutdown( - websocket_server, - &mux, - &socket_path, - remote_shutdown, - result.and(owner_event_result), - ); - served_mux_cleanup.disarm(); - drop(served_mux_cleanup); - shutdown_result - } - #[cfg(not(unix))] - { - drop(websocket_server); - mux.shutdown(); - cmux_tui_core::server::cleanup(&socket_path); - served_mux_cleanup.disarm(); - drop(served_mux_cleanup); - result.and(owner_event_result) - } + finish_server_process(server_process, result.and(owner_event_result)) } fn dispatch_local_owner_event(event: &cmux_tui_core::MuxEvent, reload: impl FnOnce()) { @@ -2094,19 +2837,14 @@ fn start_local_owner_event_loop_with_completion( LocalOwnerEventLoop { stop: Some(stop), thread: Some(thread) } } -#[cfg(unix)] -fn finish_server_shutdown( - websocket_server: Option, - mux: &Arc, +fn connect_existing_local_session( socket_path: &Path, - remote_shutdown: anyhow::Result>, - result: anyhow::Result<()>, -) -> anyhow::Result<()> { - drop(websocket_server); - mux.shutdown(); - cmux_tui_core::server::cleanup(socket_path); - remote_shutdown.map(|_| ())?; - result +) -> anyhow::Result>> { + let deadline = std::time::Instant::now() + cmux_tui_core::server::LOCAL_SOCKET_CONNECT_TIMEOUT; + let Some(stream) = cmux_tui_core::server::connect_existing_until(socket_path, deadline)? else { + return Ok(None); + }; + RemoteSession::connect_local_stream(stream, socket_path).map(Some) } #[cfg(not(unix))] @@ -2449,7 +3187,7 @@ where // the mux reaps exited surfaces itself. let events = mux.subscribe(); loop { - if shutdown_requested() || mux.daemon_shutdown_requested() { + if shutdown_requested() || mux.shutdown_requested() || mux.daemon_shutdown_requested() { break; } if remote_runtime_finished() { @@ -2593,6 +3331,301 @@ mod tests { use super::*; + fn assert_shutdown_attempt_returns( + attempt: impl Fn() -> anyhow::Result<()> + Send + Sync + 'static, + ) { + let mux = Mux::new("shutdown-cleanup-test", SurfaceOptions::default()); + let cleanup = Arc::new(ServerShutdownCleanup::new(mux)); + cleanup.set_shutdown_attempt_for_test(attempt); + let (returned, observed) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let _ = cleanup.run_until_complete(); + let _ = returned.send(()); + }); + + let result = observed.recv_timeout(Duration::from_millis(350)); + if result.is_ok() { + worker.join().unwrap(); + } + assert!(result.is_ok(), "a terminal shutdown result kept the server caller blocked"); + } + + #[test] + fn permanent_shutdown_failure_returns_to_caller() { + assert_shutdown_attempt_returns(|| anyhow::bail!("forced permanent shutdown failure")); + } + + #[test] + fn shutdown_panic_returns_to_caller() { + assert_shutdown_attempt_returns(|| panic!("forced shutdown panic")); + } + + #[cfg(unix)] + #[test] + fn server_process_owner_shutdown_obeys_cleanup_deadline() { + use std::io::{BufRead as _, Write as _}; + use std::os::unix::net::UnixStream; + + let process_owners = Arc::new(ServerProcessOwnersCleanup::new()); + let (release, blocked) = std::sync::mpsc::channel(); + let remote_thread = std::thread::spawn(move || { + let _ = blocked.recv(); + Ok(()) + }); + process_owners + .register_remote(remote_runtime::DaemonRuntimeHandle::from_test_thread(remote_thread)); + process_owners.seal(); + + let mux = Mux::new("bounded-process-owner-test", SurfaceOptions::default()); + let suffix = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let socket_dir = std::env::temp_dir() + .join(format!("cmux-owner-health-{}-{suffix:x}", std::process::id())); + std::fs::create_dir(&socket_dir).unwrap(); + let socket_path = socket_dir.join("server.sock"); + let published = + cmux_tui_core::server::serve_owned(mux.clone(), Some(socket_path.clone())).unwrap(); + let cleanup = Arc::new(ServerShutdownCleanup::new_with_process_owners(mux, process_owners)); + cleanup.set_shutdown_attempt_for_test(|| Ok(())); + cleanup.set_process_owner_timeout_for_test(Duration::from_millis(50)); + let worker_cleanup = cleanup.clone(); + let (returned, observed) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let _ = returned.send(worker_cleanup.run_until_complete()); + }); + + let bounded = observed.recv_timeout(Duration::from_millis(350)); + let returned_in_time = bounded.is_ok(); + let returned_incomplete = matches!(&bounded, Ok(Err(_))); + let retained_remote_owner = !cleanup.process_owners.remote_is_finished(); + let mut stream = UnixStream::connect(&socket_path).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + stream.write_all(b"{\"id\":1,\"cmd\":\"identify\"}\n").unwrap(); + stream.flush().unwrap(); + let mut response = String::new(); + BufReader::new(stream).read_line(&mut response).unwrap(); + let identity: serde_json::Value = serde_json::from_str(&response).unwrap(); + let health = &identity["data"]["shutdown_cleanup"]; + let reported_process_owner_degradation = health["pending"].as_u64() == Some(1) + && health["retrying"].as_bool() == Some(false) + && health["degraded"].as_bool() == Some(true); + let _ = release.send(()); + if returned_in_time { + cleanup.prepare_retry(0); + cleanup.run_until_complete().unwrap(); + } + worker.join().unwrap(); + published.cleanup(); + std::fs::remove_dir_all(socket_dir).unwrap(); + assert!(returned_in_time, "server process-owner shutdown ignored the cleanup deadline"); + assert!(returned_incomplete, "bounded owner cleanup did not request an explicit retry"); + assert!(retained_remote_owner, "bounded owner cleanup discarded the live remote runtime"); + assert!( + reported_process_owner_degradation, + "identify hid process-owner cleanup that required an explicit retry" + ); + } + + #[test] + fn failed_shutdown_retains_server_until_explicit_retry() { + let mux = Mux::new("shutdown-retry-test", SurfaceOptions::default()); + let server_process = ServerProcessShutdownGuard::start(&mux).unwrap(); + let suffix = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let socket_dir = std::env::temp_dir() + .join(format!("cmux-shutdown-retry-{}-{suffix:x}", std::process::id())); + let socket_path = socket_dir.join("server.sock"); + let published = cmux_tui_core::server::serve_owned(mux.clone(), Some(socket_path)).unwrap(); + server_process.publish_socket(published).unwrap(); + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + server_process.cleanup.set_shutdown_attempt_for_test({ + let attempts = attempts.clone(); + move || { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + anyhow::bail!("forced first shutdown failure"); + } + Ok(()) + } + }); + let (returned, observed) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = finish_server_process(server_process, Ok(())); + let _ = returned.send(result); + }); + + let first_deadline = std::time::Instant::now() + Duration::from_millis(350); + while attempts.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < first_deadline { + std::thread::yield_now(); + } + assert_eq!(attempts.load(Ordering::SeqCst), 1); + assert!( + observed.recv_timeout(Duration::from_millis(75)).is_err(), + "a failed cleanup released the server's process owners" + ); + + mux.request_shutdown(); + observed + .recv_timeout(Duration::from_secs(1)) + .expect("an explicit shutdown retry did not release the server") + .unwrap(); + worker.join().unwrap(); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + let _ = std::fs::remove_dir_all(socket_dir); + } + + #[test] + fn panicked_shutdown_retains_server_until_explicit_retry() { + let mux = Mux::new("shutdown-panic-retry-test", SurfaceOptions::default()); + let server_process = ServerProcessShutdownGuard::start(&mux).unwrap(); + let suffix = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let socket_dir = + std::env::temp_dir().join(format!("csp-{}-{suffix:x}", std::process::id())); + let socket_path = socket_dir.join("server.sock"); + let published = cmux_tui_core::server::serve_owned(mux.clone(), Some(socket_path)).unwrap(); + server_process.publish_socket(published).unwrap(); + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + server_process.cleanup.set_shutdown_attempt_for_test({ + let attempts = attempts.clone(); + move || { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + panic!("forced first shutdown panic"); + } + Ok(()) + } + }); + let cleanup = server_process.cleanup.clone(); + let (returned, observed) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = finish_server_process(server_process, Ok(())); + let _ = returned.send(result); + }); + + let first_deadline = std::time::Instant::now() + Duration::from_millis(350); + while attempts.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < first_deadline { + std::thread::yield_now(); + } + assert_eq!(attempts.load(Ordering::SeqCst), 1); + assert!( + observed.recv_timeout(Duration::from_millis(75)).is_err(), + "a panicked cleanup released the server's process owners" + ); + + mux.request_shutdown(); + let retried = observed.recv_timeout(Duration::from_secs(1)); + if retried.is_err() { + cleanup.abandon(); + let _ = observed.recv_timeout(Duration::from_secs(1)); + } + worker.join().unwrap(); + assert!(retried.is_ok(), "an explicit shutdown retry did not recover from a panic"); + retried.unwrap().unwrap(); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + let _ = std::fs::remove_dir_all(socket_dir); + } + + #[test] + fn unpublished_shutdown_failure_returns_without_waiting_for_retry() { + let mux = Mux::new("unpublished-shutdown-test", SurfaceOptions::default()); + let server_process = ServerProcessShutdownGuard::start(&mux).unwrap(); + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + server_process.cleanup.set_shutdown_attempt_for_test({ + let attempts = attempts.clone(); + move || { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + anyhow::bail!("forced unpublished shutdown failure"); + } + Ok(()) + } + }); + let (returned, observed) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = finish_server_process(server_process, Ok(())); + let _ = returned.send(result); + }); + + let first_deadline = std::time::Instant::now() + Duration::from_millis(350); + while attempts.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < first_deadline { + std::thread::yield_now(); + } + assert_eq!(attempts.load(Ordering::SeqCst), 1); + let early = observed.recv_timeout(Duration::from_millis(75)); + if early.is_err() { + mux.request_shutdown(); + let _ = observed.recv_timeout(Duration::from_secs(1)); + } + worker.join().unwrap(); + + assert!(early.is_ok(), "an unpublished server waited on its unreachable control socket"); + assert!(early.unwrap().is_err(), "failed unpublished cleanup reported success"); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[cfg(unix)] + #[test] + fn remote_runtime_start_failure_cleans_published_server() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let directory = + PathBuf::from("/tmp").join(format!("crs-{}-{suffix:x}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let socket_path = directory.join("server.sock"); + let remote_state_file = directory.join("remote-state-file"); + std::fs::write(&remote_state_file, b"not a directory").unwrap(); + let mut server_args = args(&["--ephemeral", "--headless", "--remote"]); + server_args.session = format!("remote-start-cleanup-{suffix:x}"); + server_args.socket = Some(socket_path.clone()); + server_args.remote_state_dir = Some(remote_state_file); + + let shutdown_attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + set_server_shutdown_attempt_for_run(Some({ + let shutdown_attempts = shutdown_attempts.clone(); + Arc::new(move || { + shutdown_attempts.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + })); + + let result = run_server(server_args, None); + set_server_shutdown_attempt_for_run(None); + let shutdown_attempt_count = shutdown_attempts.load(Ordering::SeqCst); + let socket_remained = socket_path.exists(); + cmux_tui_core::server::cleanup(&socket_path); + std::fs::remove_dir_all(directory).unwrap(); + + assert!(result.is_err(), "invalid remote state unexpectedly started the server"); + assert_eq!( + shutdown_attempt_count, 1, + "remote startup failure bypassed server process cleanup" + ); + assert!(!socket_remained, "remote startup failure retained the published server socket"); + } + + #[test] + fn remote_shutdown_failure_preserves_an_earlier_server_error() { + let error = combine_server_and_remote_shutdown_results( + Err(anyhow::anyhow!("frontend failed")), + Err(anyhow::anyhow!("remote shutdown failed")), + ) + .unwrap_err(); + let message = format!("{error:#}"); + + assert!(message.contains("frontend failed")); + assert!(message.contains("remote shutdown failed")); + } + + #[test] + fn remote_shutdown_failure_is_returned_after_successful_server_execution() { + let error = combine_server_and_remote_shutdown_results( + Ok(()), + Err(anyhow::anyhow!("remote shutdown failed")), + ) + .unwrap_err(); + + assert_eq!(error.to_string(), "remote shutdown failed"); + } + fn args(values: &[&str]) -> Args { parse_args_result(values.iter().map(|value| value.to_string())).unwrap() } @@ -2711,55 +3744,111 @@ mod tests { #[cfg(unix)] #[test] - fn remote_shutdown_failure_still_stops_the_mux_and_removes_the_socket() { - let socket_path = std::env::temp_dir().join(format!( - "cmux-remote-shutdown-{}-{}.sock", - std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() - )); - std::fs::write(&socket_path, b"test socket marker").unwrap(); - let mux = Mux::new("remote-shutdown-failure", SurfaceOptions::default()); - - let error = finish_server_shutdown( - Some(()), - &mux, - &socket_path, - Err::, _>(anyhow::anyhow!("injected remote shutdown failure")), - Ok(()), + fn failed_server_publication_preserves_live_socket() { + use std::os::unix::net::{UnixListener, UnixStream}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket_dir = + std::env::temp_dir().join(format!("cg-publish-{}-{suffix:x}", std::process::id())); + std::fs::create_dir(&socket_dir).unwrap(); + let socket_path = socket_dir.join("server.sock"); + let existing = UnixListener::bind(&socket_path).unwrap(); + let mux = Mux::new("server-publication-test", SurfaceOptions::default()); + let guard = ServerProcessShutdownGuard::start(&mux).unwrap(); + + let error = finish_server_process( + guard, + cmux_tui_core::server::serve(mux, Some(socket_path.clone())).map(|_| ()), ) - .unwrap_err() - .to_string(); + .expect_err("duplicate server publication unexpectedly succeeded"); + assert!( + format!("{error:#}").contains("is already in use"), + "unexpected server publication error: {error:#}" + ); - assert!(error.contains("injected remote shutdown failure"), "{error}"); - assert!(mux.daemon_shutdown_requested()); - assert!(!socket_path.exists()); + let client = UnixStream::connect(&socket_path) + .expect("failed publication cleanup unlinked the live server socket"); + drop(client); + drop(existing); + cmux_tui_core::server::cleanup(&socket_path); + std::fs::remove_dir_all(socket_dir).unwrap(); } #[cfg(unix)] #[test] - fn normal_server_cleanup_disarms_the_fallback_guard() { - let socket_path = std::env::temp_dir().join(format!( - "cmux-normal-shutdown-{}-{}.sock", - std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() - )); - std::fs::write(&socket_path, b"test socket marker").unwrap(); - let mux = Mux::new("normal-shutdown", SurfaceOptions::default()); - let mut cleanup = ServedMuxCleanup::new(mux.clone(), socket_path.clone()); - - finish_server_shutdown( - Some(()), - &mux, - &socket_path, - Ok::, anyhow::Error>(None), - Ok(()), - ) - .unwrap(); - cleanup.disarm(); + fn completed_server_guard_never_unlinks_a_replacement_socket() { + use std::os::unix::net::{UnixListener, UnixStream}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket_path = + std::env::temp_dir().join(format!("cg-{}-{suffix:x}.sock", std::process::id())); + let original = UnixListener::bind(&socket_path).unwrap(); + let published = cmux_tui_core::server::PublishedSocket::claim(socket_path.clone()).unwrap(); + let mux = Mux::new("server-guard-test", SurfaceOptions::default()); + let shutdown_watch = mux.watch_shutdown_request(); + let cleanup = Arc::new(ServerShutdownCleanup::new(mux)); + *cleanup.state.lock().unwrap() = ServerShutdownCleanupState::Complete { error: None }; + let process_owners = cleanup.process_owners.clone(); + let (completion, completed) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || completed.recv().unwrap()); + let guard = ServerProcessShutdownGuard { + published_socket: Arc::new(std::sync::Mutex::new(Some(published))), + shutdown_watch, + process_owners, + cleanup, + completion: Some(completion), + worker: Some(worker), + }; + std::fs::remove_file(&socket_path).unwrap(); + let replacement = UnixListener::bind(&socket_path).unwrap(); + + guard.complete(); + + let client = UnixStream::connect(&socket_path) + .expect("guard completion unlinked the replacement server socket"); + drop(client); + drop(replacement); + drop(original); + cmux_tui_core::server::cleanup(&socket_path); + } - assert!(cleanup.mux.is_none()); - assert!(mux.daemon_shutdown_requested()); - assert!(!socket_path.exists()); + #[cfg(unix)] + #[test] + fn incomplete_server_guard_retains_its_control_socket() { + use std::os::unix::net::{UnixListener, UnixStream}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket_path = std::env::temp_dir() + .join(format!("cg-incomplete-{}-{suffix:x}.sock", std::process::id())); + let listener = UnixListener::bind(&socket_path).unwrap(); + let published = cmux_tui_core::server::PublishedSocket::claim(socket_path.clone()).unwrap(); + let mux = Mux::new("incomplete-server-guard-test", SurfaceOptions::default()); + let shutdown_watch = mux.watch_shutdown_request(); + let cleanup = Arc::new(ServerShutdownCleanup::new(mux)); + let process_owners = cleanup.process_owners.clone(); + let (completion, completed) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let _ = completed.recv(); + }); + let guard = ServerProcessShutdownGuard { + published_socket: Arc::new(std::sync::Mutex::new(Some(published))), + shutdown_watch, + process_owners, + cleanup, + completion: Some(completion), + worker: Some(worker), + }; + + drop(guard); + + let client = UnixStream::connect(&socket_path) + .expect("incomplete server guard released its control socket"); + drop(client); + drop(listener); + cmux_tui_core::server::cleanup(&socket_path); } #[cfg(unix)] diff --git a/cmux-tui/crates/cmux-tui/src/plugin_manager.rs b/cmux-tui/crates/cmux-tui/src/plugin_manager.rs index bb5b5ccde33c..9c4c0f98d61e 100644 --- a/cmux-tui/crates/cmux-tui/src/plugin_manager.rs +++ b/cmux-tui/crates/cmux-tui/src/plugin_manager.rs @@ -421,12 +421,9 @@ fn installed_name( fn run_build_if_needed(manifest: &PluginManifest, dir: &Path) -> anyhow::Result<()> { let Some(build) = &manifest.build else { return Ok(()) }; - let status = Command::new(&build.command[0]) - .args(&build.command[1..]) - .current_dir(dir) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status()?; + let mut command = Command::new(&build.command[0]); + command.args(&build.command[1..]).current_dir(dir).stdout(Stdio::null()).stderr(Stdio::null()); + let status = cmux_tui_process::spawn(&mut command)?.wait()?; if !status.success() { anyhow::bail!("build command failed with status {status}"); } @@ -474,7 +471,7 @@ fn run_git( command.current_dir(dir); } command.stdout(Stdio::null()).stderr(Stdio::null()); - let status = command.status()?; + let status = cmux_tui_process::spawn(&mut command)?.wait()?; if !status.success() { anyhow::bail!("git failed with status {status}"); } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 6e07a0f1fe3a..c6b580697ba6 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -1428,7 +1428,7 @@ mod tests { assert_eq!(dispatcher.enqueue(input()), PtyInputEnqueueResult::Accepted); assert!(dispatcher.shutdown(Duration::from_secs(1))); - mux.shutdown(); + let _ = mux.shutdown(); } #[test] @@ -1459,7 +1459,7 @@ mod tests { assert!(failure.lane_failed); assert_eq!(failure.label, "PTY input"); - mux.shutdown(); + let _ = mux.shutdown(); } fn mutation_with_retained_bytes(retained_bytes: usize) -> PtyInputEvent { diff --git a/cmux-tui/crates/cmux-tui/src/remote_cli.rs b/cmux-tui/crates/cmux-tui/src/remote_cli.rs index 340634b7bd3e..40aa6d937df7 100644 --- a/cmux-tui/crates/cmux-tui/src/remote_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/remote_cli.rs @@ -1904,8 +1904,8 @@ fn run_remote_stop(args: &[String]) -> anyhow::Result<()> { let runtime = match load_runtime_info(&parsed.session, parsed.state_dir.as_deref()) { Ok(runtime) => runtime, Err(_) - if UnixStream::connect(&default_link).is_err() - && UnixStream::connect(&default_admin).is_err() => + if cmux_tui_process::unix::connect_stream(&default_link).is_err() + && cmux_tui_process::unix::connect_stream(&default_admin).is_err() => { if parsed.acknowledge_failed_finalization { return tokio_runtime()?.block_on(acknowledge_failed_shutdown_outcome( @@ -1987,7 +1987,10 @@ fn run_remote_stop(args: &[String]) -> anyhow::Result<()> { while Instant::now() < deadline { let process_exited = peer_exit.has_exited().context(catalog().remote.observe_daemon_exit)?; - if process_exited && UnixStream::connect(&link).is_err() && !runtime_file.exists() { + if process_exited + && cmux_tui_process::unix::connect_stream(&link).is_err() + && !runtime_file.exists() + { match lifecycle_id.as_deref() { Some(lifecycle_id) => verify_shutdown_outcome(&state_dir, lifecycle_id), None => Ok(()), @@ -2108,7 +2111,7 @@ fn ensure_daemon( mux_socket_override: Option<&Path>, ) -> anyhow::Result<()> { let _lock = lock_daemon_start(session_state)?; - if UnixStream::connect(link).is_ok() { + if cmux_tui_process::unix::connect_stream(link).is_ok() { return Ok(()); } @@ -2118,7 +2121,7 @@ fn ensure_daemon( .map(Path::to_path_buf) .or_else(|| std::env::var_os("CMUX_MUX_SOCKET").map(PathBuf::from)) .unwrap_or_else(|| cmux_tui_core::server::default_socket_path(session)); - if UnixStream::connect(&mux_socket).is_err() { + if cmux_tui_process::unix::connect_stream(&mux_socket).is_err() { let log = OpenOptions::new().create(true).append(true).open(&log_path)?; let mut mux_owner = Command::new(&executable); mux_owner @@ -2128,7 +2131,8 @@ fn ensure_daemon( .stdout(Stdio::from(log.try_clone()?)) .stderr(Stdio::from(log)); configure_detached_process(&mut mux_owner); - let mut child = mux_owner.spawn().context("could not start remote mux owner")?; + let mut child = + cmux_tui_process::spawn(&mut mux_owner).context("could not start remote mux owner")?; wait_for_detached_socket( &mut child, &mux_socket, @@ -2151,7 +2155,8 @@ fn ensure_daemon( command.stdin(Stdio::null()); command.stdout(Stdio::from(log.try_clone()?)).stderr(Stdio::from(log)); configure_detached_process(&mut command); - let mut child = command.spawn().context("could not start remote daemon")?; + let mut child = + cmux_tui_process::spawn(&mut command).context("could not start remote daemon")?; wait_for_detached_socket(&mut child, link, Duration::from_secs(20), "remote daemon", &log_path) } @@ -2164,7 +2169,7 @@ fn wait_for_detached_socket( ) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - if UnixStream::connect(socket).is_ok() { + if cmux_tui_process::unix::connect_stream(socket).is_ok() { return Ok(()); } match child.try_wait() { @@ -2254,7 +2259,7 @@ fn configure_detached_process(command: &mut Command) { } fn open_mux_monitor(path: &Path) -> anyhow::Result { - let stream = UnixStream::connect(path).with_context(|| { + let stream = cmux_tui_process::unix::connect_stream(path).with_context(|| { format!("cannot attach remote sidecar to mux socket {}", path.display()) })?; stream.set_read_timeout(Some(Duration::from_millis(250)))?; @@ -2327,7 +2332,7 @@ where { use tokio::io::{AsyncWriteExt, copy}; - let stream = tokio::net::UnixStream::connect(link).await?; + let stream = cmux_tui_process::tokio_net::connect_unix_stream(link).await?; verify_peer(&stream)?; let (mut socket_read, mut socket_write) = stream.into_split(); let upload = async { diff --git a/cmux-tui/crates/cmux-tui/src/remote_runtime.rs b/cmux-tui/crates/cmux-tui/src/remote_runtime.rs index 751afa4f143e..e338cf3fa7d6 100644 --- a/cmux-tui/crates/cmux-tui/src/remote_runtime.rs +++ b/cmux-tui/crates/cmux-tui/src/remote_runtime.rs @@ -7,9 +7,9 @@ use std::fs::{self, OpenOptions}; use std::future::Future; use std::net::SocketAddr; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc; +use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::Duration; @@ -83,16 +83,14 @@ struct DaemonCleanupPause { expected_state_dir: PathBuf, expected_phase: DaemonCleanupPausePhase, reached: mpsc::SyncSender<()>, - resume: std::sync::Mutex>, + resume: Mutex>, } #[cfg(test)] -static DAEMON_CLEANUP_PAUSES: std::sync::Mutex>> = - std::sync::Mutex::new(Vec::new()); +static DAEMON_CLEANUP_PAUSES: Mutex>> = Mutex::new(Vec::new()); #[cfg(test)] -static STATE_DIRECTORY_SYNC_FAILURES: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); +static STATE_DIRECTORY_SYNC_FAILURES: Mutex> = Mutex::new(Vec::new()); #[cfg(test)] struct DaemonCleanupPauseHandle { @@ -110,7 +108,7 @@ impl DaemonCleanupPauseHandle { expected_state_dir, expected_phase, reached: reached_tx, - resume: std::sync::Mutex::new(resume_rx), + resume: Mutex::new(resume_rx), }); let mut installed = DAEMON_CLEANUP_PAUSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -340,6 +338,57 @@ pub struct DaemonRuntimeHandle { info: DaemonRuntimeInfo, shutdown: watch::Sender, thread: Option>>, + completion: Arc, + #[cfg(debug_assertions)] + shutdown_test_started: bool, + #[cfg(debug_assertions)] + shutdown_test_completed: bool, +} + +#[derive(Default)] +struct RuntimeThreadCompletion { + finished: Mutex, + changed: Condvar, +} + +impl RuntimeThreadCompletion { + fn mark_finished(&self) { + *self.finished.lock().unwrap() = true; + self.changed.notify_all(); + } + + fn is_finished(&self) -> bool { + *self.finished.lock().unwrap() + } + + fn wait_until(&self, deadline: std::time::Instant) -> bool { + let finished = self.finished.lock().unwrap(); + if *finished { + return true; + } + let (finished, _) = self + .changed + .wait_timeout_while( + finished, + deadline.saturating_duration_since(std::time::Instant::now()), + |finished| !*finished, + ) + .unwrap(); + *finished + } +} + +struct RuntimeThreadCompletionGuard(Arc); + +impl Drop for RuntimeThreadCompletionGuard { + fn drop(&mut self) { + self.0.mark_finished(); + } +} + +pub(crate) enum DaemonRuntimeShutdown { + Complete(anyhow::Result<()>), + Pending, } impl DaemonRuntimeHandle { @@ -348,16 +397,123 @@ impl DaemonRuntimeHandle { } pub fn is_finished(&self) -> bool { - self.thread.as_ref().is_some_and(thread::JoinHandle::is_finished) + self.completion.is_finished() } - pub fn shutdown(mut self) -> anyhow::Result<()> { + fn request_shutdown(&self) { let _ = self.shutdown.send(true); - match self.thread.take().expect("daemon runtime thread is present").join() { + } + + #[cfg(debug_assertions)] + fn begin_shutdown_test_hooks(&mut self) -> anyhow::Result<()> { + if self.shutdown_test_started { + return Ok(()); + } + if let Some(signal) = std::env::var_os("CMUX_TUI_TEST_REMOTE_SHUTDOWN_STARTED_SIGNAL") { + fs::write(signal, b"1")?; + } + if let Some(gate) = std::env::var_os("CMUX_TUI_TEST_REMOTE_SHUTDOWN_GATE") { + use std::io::Read as _; + + let mut gate = fs::File::open(gate)?; + let mut signal = [0_u8; 1]; + gate.read_exact(&mut signal)?; + } + self.shutdown_test_started = true; + Ok(()) + } + + #[cfg(debug_assertions)] + fn complete_shutdown_test_hooks(&mut self) -> anyhow::Result<()> { + if self.shutdown_test_completed { + return Ok(()); + } + if let Some(signal) = std::env::var_os("CMUX_TUI_TEST_REMOTE_SHUTDOWN_COMPLETE_SIGNAL") { + fs::write(signal, b"1")?; + } + self.shutdown_test_completed = true; + Ok(()) + } + + pub(crate) fn shutdown_until(&mut self, deadline: std::time::Instant) -> DaemonRuntimeShutdown { + #[cfg(debug_assertions)] + if let Err(error) = self.begin_shutdown_test_hooks() { + return DaemonRuntimeShutdown::Complete(Err(error)); + } + self.request_shutdown(); + if self.thread.is_none() { + return DaemonRuntimeShutdown::Complete(Err(anyhow!( + "remote daemon runtime thread is absent" + ))); + } + if self.completion.wait_until(deadline) { + let result = self.join_runtime_thread(); + #[cfg(debug_assertions)] + if let Err(error) = self.complete_shutdown_test_hooks() { + return DaemonRuntimeShutdown::Complete(Err(error)); + } + DaemonRuntimeShutdown::Complete(result) + } else { + DaemonRuntimeShutdown::Pending + } + } + + fn join_runtime_thread(&mut self) -> anyhow::Result<()> { + match self + .thread + .take() + .ok_or_else(|| anyhow!("remote daemon runtime thread is absent"))? + .join() + { Ok(result) => result, Err(_) => Err(anyhow!("remote daemon runtime thread panicked")), } } + + #[cfg(test)] + pub(crate) fn from_test_thread(thread: thread::JoinHandle>) -> Self { + let (shutdown, _) = watch::channel(false); + let completion = Arc::new(RuntimeThreadCompletion::default()); + let worker_completion = completion.clone(); + let thread = thread::spawn(move || { + let _completion = RuntimeThreadCompletionGuard(worker_completion); + match thread.join() { + Ok(result) => result, + Err(_) => Err(anyhow!("remote daemon runtime thread panicked")), + } + }); + Self { + info: DaemonRuntimeInfo { + session: "test".into(), + state_dir: PathBuf::new(), + link_socket: PathBuf::new(), + admin_socket: PathBuf::new(), + daemon_fingerprint: String::new(), + routes: Vec::new(), + direct_websocket: None, + iroh_node_id: None, + lifecycle_id: None, + replaceable_sidecar: false, + }, + shutdown, + thread: Some(thread), + completion, + #[cfg(debug_assertions)] + shutdown_test_started: false, + #[cfg(debug_assertions)] + shutdown_test_completed: false, + } + } + + pub fn shutdown(mut self) -> anyhow::Result<()> { + #[cfg(debug_assertions)] + self.begin_shutdown_test_hooks()?; + self.request_shutdown(); + let result = self.join_runtime_thread(); + #[cfg(debug_assertions)] + self.complete_shutdown_test_hooks()?; + result + } } impl Drop for DaemonRuntimeHandle { @@ -694,7 +850,7 @@ impl ClientSocketLease { fn bind_locked(path: PathBuf) -> std::io::Result { use std::os::unix::fs::{FileTypeExt, MetadataExt}; - let listener = tokio::net::UnixListener::bind(&path)?; + let listener = cmux_tui_process::tokio_net::bind_unix_listener(&path)?; let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(error) => { @@ -1482,7 +1638,7 @@ async fn prepare_client_socket_with_shutdown( } match tokio::time::timeout( UNIX_SOCKET_PROBE_TIMEOUT, - tokio::net::UnixStream::connect(path), + cmux_tui_process::tokio_net::connect_unix_stream(path), ) .await { @@ -1741,9 +1897,12 @@ fn start_daemon_runtime_with_timeout( let (shutdown_tx, shutdown_rx) = watch::channel(false); let owner_shutdown = shutdown_tx.clone(); let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let completion = Arc::new(RuntimeThreadCompletion::default()); + let worker_completion = completion.clone(); let thread = thread::Builder::new() .name(format!("cmux-remote-{}", options.session)) .spawn(move || { + let _completion = RuntimeThreadCompletionGuard(worker_completion); let runtime = build_remote_runtime("cmux-remote-daemon-worker")?; let result = runtime.block_on(run_daemon( mux_socket, @@ -1773,7 +1932,16 @@ fn start_daemon_runtime_with_timeout( return Err(anyhow!("remote daemon did not become ready: {error}")); } }; - Ok(DaemonRuntimeHandle { info, shutdown: shutdown_tx, thread: Some(thread) }) + Ok(DaemonRuntimeHandle { + info, + shutdown: shutdown_tx, + thread: Some(thread), + completion, + #[cfg(debug_assertions)] + shutdown_test_started: false, + #[cfg(debug_assertions)] + shutdown_test_completed: false, + }) } #[allow(clippy::too_many_arguments)] @@ -2674,7 +2842,9 @@ async fn verify_recovery_sockets_inactive( } } for socket in sockets { - match bounded_unix_socket_connect(tokio::net::UnixStream::connect(socket)).await { + match bounded_unix_socket_connect(cmux_tui_process::tokio_net::connect_unix_stream(socket)) + .await + { Ok(_) => { return Err(anyhow!( catalog() @@ -3048,7 +3218,7 @@ mod tests { #[derive(Default)] struct FakeReconnectRouteAttempt { - requests: std::sync::Mutex>, + requests: Mutex>, } #[async_trait] @@ -3095,7 +3265,7 @@ mod tests { #[derive(Default)] struct AuthRecordingReconnectRouteAttempt { - auth: std::sync::Mutex>, + auth: Mutex>, } #[async_trait] diff --git a/cmux-tui/crates/cmux-tui/src/server_lifecycle.rs b/cmux-tui/crates/cmux-tui/src/server_lifecycle.rs new file mode 100644 index 000000000000..3347e1087670 --- /dev/null +++ b/cmux-tui/crates/cmux-tui/src/server_lifecycle.rs @@ -0,0 +1,2172 @@ +use std::collections::HashSet; +#[cfg(unix)] +use std::ffi::CString; +use std::ffi::OsStr; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::process::{Child, Command, ExitStatus, Stdio}; +#[cfg(unix)] +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +#[cfg(unix)] +use std::sync::{Arc, Condvar, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use cmux_tui_core::PublicationGuard; +use cmux_tui_core::platform::transport; +use cmux_tui_core::release::{LAUNCHER_COMMAND_ENV, ReleaseIdentity}; +use cmux_tui_core::server::{ + LOCAL_SOCKET_CONNECT_TIMEOUT, PROTOCOL_VERSION, SERVER_SHUTDOWN_CAPABILITY, + SERVER_SHUTDOWN_TIMEOUT, +}; +use serde_json::{Value, json}; + +#[cfg(unix)] +mod legacy_process; +#[cfg(all(unix, test))] +use legacy_process::terminate_process_tree; +#[cfg(unix)] +use legacy_process::{ + CapturedProcessSession, CapturedProcessTree, FrozenProcessTree, ProcessIdentity, + capture_process_session, capture_process_tree_until, freeze_process_tree_until, +}; + +const PROBE_REQUEST_ID: u64 = 0; +#[cfg(unix)] +const LEGACY_LIST_REQUEST_ID: u64 = 2; +#[cfg(unix)] +const LEGACY_STABLE_EMPTY_SCANS: usize = 2; +#[cfg(unix)] +const LEGACY_MAX_SCAN_ROUNDS: usize = 64; +const MAX_LIFECYCLE_RESPONSE_BYTES: usize = 16 * 1024 * 1024; +const SHUTDOWN_TRANSPORT_MARGIN: Duration = Duration::from_secs(5); +#[cfg(unix)] +const LEGACY_SHUTDOWN_TIMEOUT: Duration = + SERVER_SHUTDOWN_TIMEOUT.saturating_add(SHUTDOWN_TRANSPORT_MARGIN); +#[cfg(unix)] +const LEGACY_HELPER_WAIT_MARGIN: Duration = Duration::from_millis(250); +#[cfg(unix)] +const LEGACY_HELPER_POLL_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(unix)] +const LEGACY_CLIENT_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(unix)] +const LEGACY_HELPER_CANCEL_MARGIN: Duration = Duration::from_millis(100); +#[cfg(unix)] +const LEGACY_HELPER_REAPER_CAPACITY: usize = 64; +#[cfg(unix)] +const LEGACY_HELPER_REAPER_RETRY_MAX: Duration = Duration::from_secs(1); +const MAX_LAUNCHER_COMMAND_BYTES: usize = 4096; + +type TransportReader = BufReader>; + +#[cfg(unix)] +static LEGACY_HELPER_CANCELLED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] +static LEGACY_SOCKET_QUARANTINE_SEQUENCE: AtomicUsize = AtomicUsize::new(1); +#[cfg(unix)] +static LEGACY_HELPER_REAPER: OnceLock> = OnceLock::new(); +#[cfg(all(unix, test))] +static LEGACY_HELPER_REAPER_TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(unix)] +fn atomic_rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> { + use std::os::unix::ffi::OsStrExt as _; + + let from = CString::new(from.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "source path contains a NUL byte") + })?; + let to = CString::new(to.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "target path contains a NUL byte") + })?; + #[cfg(target_vendor = "apple")] + let result = unsafe { + libc::renameatx_np( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_EXCL, + ) + }; + #[cfg(target_os = "linux")] + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + #[cfg(not(any(target_vendor = "apple", target_os = "linux")))] + let result = { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic no-replace rename is unavailable on this platform", + )); + }; + if result == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) } +} + +#[cfg(unix)] +struct LegacySocketQuarantine { + original: PathBuf, + quarantined: PathBuf, + device: u64, + inode: u64, + armed: bool, + publication_guard: PublicationGuard, +} + +#[cfg(unix)] +impl LegacySocketQuarantine { + fn acquire(original: &Path) -> std::io::Result { + Self::acquire_with_transfer_hook(original, || {}) + } + + fn acquire_with_transfer_hook( + original: &Path, + transfer_hook: impl FnOnce(), + ) -> std::io::Result { + use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _}; + + let publication_guard = PublicationGuard::acquire( + original, + Instant::now() + LOCAL_SOCKET_CONNECT_TIMEOUT + LOCAL_SOCKET_CONNECT_TIMEOUT, + )?; + let metadata = std::fs::symlink_metadata(original)?; + if !metadata.file_type().is_socket() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy control path is not a socket", + )); + } + let parent = original.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "legacy control socket has no parent directory", + ) + })?; + let mut quarantined = None; + for _ in 0..64 { + let sequence = LEGACY_SOCKET_QUARANTINE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let candidate = parent.join(format!(".cs-{:x}-{sequence:x}", std::process::id())); + match std::fs::symlink_metadata(&candidate) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + quarantined = Some(candidate); + break; + } + Ok(_) => {} + Err(error) => return Err(error), + } + } + let quarantined = quarantined.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not reserve a private legacy control socket path", + ) + })?; + transfer_hook(); + atomic_rename_noreplace(original, &quarantined)?; + let moved = match std::fs::symlink_metadata(&quarantined) { + Ok(moved) => moved, + Err(error) => { + let _ = atomic_rename_noreplace(&quarantined, original); + return Err(error); + } + }; + if moved.dev() != metadata.dev() + || moved.ino() != metadata.ino() + || !moved.file_type().is_socket() + { + atomic_rename_noreplace(&quarantined, original).map_err(|restore_error| { + std::io::Error::new( + restore_error.kind(), + format!( + "legacy control socket identity changed and its replacement could not be restored: {restore_error}" + ), + ) + })?; + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy control socket identity changed during quarantine", + )); + } + Ok(Self { + original: original.to_path_buf(), + quarantined, + device: metadata.dev(), + inode: metadata.ino(), + armed: true, + publication_guard, + }) + } + + fn adopt( + original: PathBuf, + quarantined: PathBuf, + device: u64, + inode: u64, + publication_guard: PublicationGuard, + ) -> std::io::Result { + let quarantine = + Self { original, quarantined, device, inode, armed: true, publication_guard }; + if quarantine.matches(&quarantine.quarantined)? != Some(true) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "private legacy control socket identity changed", + )); + } + Ok(quarantine) + } + + fn path(&self) -> &Path { + &self.quarantined + } + + fn matches(&self, path: &Path) -> std::io::Result> { + use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _}; + + match std::fs::symlink_metadata(path) { + Ok(metadata) => Ok(Some( + metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode, + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } + + fn restore(&mut self) -> std::io::Result<()> { + if !self.armed { + return Ok(()); + } + match (self.matches(&self.original)?, self.matches(&self.quarantined)?) { + (Some(true), Some(true)) => { + std::fs::remove_file(&self.quarantined)?; + } + (Some(true), None) => {} + (None, Some(true)) => { + atomic_rename_noreplace(&self.quarantined, &self.original)?; + } + (Some(false), _) | (_, Some(false)) => { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "legacy control socket path was replaced during shutdown", + )); + } + (None, None) => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "legacy control socket disappeared during shutdown", + )); + } + } + self.armed = false; + Ok(()) + } + + fn commit(&mut self) -> std::io::Result<()> { + if !self.armed { + return Ok(()); + } + let original = self.matches(&self.original)?; + let quarantined = self.matches(&self.quarantined)?; + if original == Some(false) || quarantined == Some(false) { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "legacy control socket path was replaced during shutdown", + )); + } + if original == Some(true) { + if quarantined == Some(true) { + std::fs::remove_file(&self.quarantined)?; + } + atomic_rename_noreplace(&self.original, &self.quarantined)?; + if self.matches(&self.quarantined)? != Some(true) { + let _ = atomic_rename_noreplace(&self.quarantined, &self.original); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy control socket identity changed during removal", + )); + } + } + if self.matches(&self.quarantined)? == Some(true) { + std::fs::remove_file(&self.quarantined)?; + } + self.armed = false; + Ok(()) + } +} + +#[cfg(unix)] +impl Drop for LegacySocketQuarantine { + fn drop(&mut self) { + let _ = self.restore(); + } +} + +#[cfg(unix)] +struct LegacySurfaceCleanup { + owners: Vec, + frozen_server: Option, +} + +#[cfg(unix)] +struct LegacyHelperReaper { + pending: Arc<(Mutex>, Condvar)>, + active: Arc, + _worker: std::thread::JoinHandle<()>, +} + +#[cfg(unix)] +struct LegacyHelperChild { + child: Child, + quarantine: LegacySocketQuarantine, +} + +#[cfg(unix)] +impl LegacyHelperChild { + fn id(&self) -> u32 { + self.child.id() + } + + fn try_wait(&mut self) -> std::io::Result> { + self.child.try_wait() + } + + fn settle(mut self, status: ExitStatus) -> std::io::Result<()> { + if status.success() { self.quarantine.commit() } else { self.quarantine.restore() } + } + + fn restore(mut self) { + let _ = self.quarantine.restore(); + } +} + +#[cfg(unix)] +struct LegacyHelperReap { + helper: LegacyHelperChild, + _lease: LegacyHelperReaperLease, + next_attempt: Instant, + retry_delay: Duration, +} + +#[cfg(unix)] +struct LegacyHelperReaperLease { + pending: Arc<(Mutex>, Condvar)>, + active: Arc, +} + +#[cfg(unix)] +impl Drop for LegacyHelperReaperLease { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + self.pending.1.notify_all(); + } +} + +#[cfg(unix)] +impl LegacyHelperReaperLease { + fn retain_shutdown_ownership(self, helper: LegacyHelperChild) { + let pending = self.pending.clone(); + pending.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push( + LegacyHelperReap { + helper, + _lease: self, + next_attempt: Instant::now(), + retry_delay: LEGACY_HELPER_POLL_INTERVAL, + }, + ); + pending.1.notify_one(); + } +} + +#[cfg(unix)] +impl LegacyHelperReaper { + fn start() -> std::io::Result { + let pending = Arc::new((Mutex::new(Vec::new()), Condvar::new())); + let active = Arc::new(AtomicUsize::new(0)); + let worker_pending = pending.clone(); + let worker = std::thread::Builder::new() + .name("cmux-legacy-helper-reaper".into()) + .spawn(move || run_legacy_helper_reaper(worker_pending))?; + Ok(Self { pending, active, _worker: worker }) + } + + fn reserve(&self) -> Option { + self.active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < LEGACY_HELPER_REAPER_CAPACITY).then_some(active + 1) + }) + .ok()?; + self.pending.1.notify_all(); + Some(LegacyHelperReaperLease { pending: self.pending.clone(), active: self.active.clone() }) + } +} + +#[cfg(unix)] +fn reserve_legacy_helper_reaper() -> anyhow::Result { + let reaper = LEGACY_HELPER_REAPER.get_or_init(|| LegacyHelperReaper::start().ok()); + reaper + .as_ref() + .and_then(LegacyHelperReaper::reserve) + .ok_or_else(|| anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed)) +} + +#[cfg(all(unix, test))] +fn active_legacy_helper_reapers_for_test() -> usize { + LEGACY_HELPER_REAPER + .get() + .and_then(Option::as_ref) + .map_or(0, |reaper| reaper.active.load(Ordering::Acquire)) +} + +#[cfg(all(unix, test))] +fn wait_for_active_legacy_helper_reapers_for_test(expected: usize, deadline: Instant) -> bool { + let Some(reaper) = LEGACY_HELPER_REAPER.get().and_then(Option::as_ref) else { + return expected == 0; + }; + let mut pending = reaper.pending.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + while reaper.active.load(Ordering::Acquire) != expected { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let (next, timeout) = reaper + .pending + .1 + .wait_timeout(pending, remaining) + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending = next; + if timeout.timed_out() && reaper.active.load(Ordering::Acquire) != expected { + return false; + } + } + true +} + +#[cfg(unix)] +fn run_legacy_helper_reaper(pending: Arc<(Mutex>, Condvar)>) { + let mut pending_children = pending.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + while pending_children.is_empty() { + pending_children = + pending.1.wait(pending_children).unwrap_or_else(std::sync::PoisonError::into_inner); + } + let now = Instant::now(); + let next_attempt = + pending_children.iter().map(|entry| entry.next_attempt).min().unwrap_or(now); + if next_attempt > now { + let waited = pending + .1 + .wait_timeout(pending_children, next_attempt.saturating_duration_since(now)) + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending_children = waited.0; + continue; + } + let mut index = 0; + while index < pending_children.len() { + if pending_children[index].next_attempt > now { + index += 1; + continue; + } + match pending_children[index].helper.try_wait() { + Ok(Some(status)) => { + let entry = pending_children.swap_remove(index); + let _ = entry.helper.settle(status); + } + Err(error) if error.raw_os_error() == Some(libc::ECHILD) => { + let entry = pending_children.swap_remove(index); + entry.helper.restore(); + } + Ok(None) | Err(_) => { + let entry = &mut pending_children[index]; + entry.next_attempt = Instant::now() + entry.retry_delay; + entry.retry_delay = (entry.retry_delay * 2).min(LEGACY_HELPER_REAPER_RETRY_MAX); + index += 1; + } + } + } + } +} + +#[cfg(unix)] +extern "C" fn handle_legacy_helper_signal(_: libc::c_int) { + LEGACY_HELPER_CANCELLED.store(true, Ordering::Release); +} + +#[cfg(unix)] +fn install_legacy_helper_signal_handler() { + unsafe { + libc::signal(libc::SIGTERM, handle_legacy_helper_signal as *const () as libc::sighandler_t); + } +} + +#[cfg(unix)] +fn legacy_helper_cancelled() -> bool { + LEGACY_HELPER_CANCELLED.load(Ordering::Acquire) +} + +#[cfg(unix)] +fn ensure_legacy_helper_active() -> anyhow::Result<()> { + if legacy_helper_cancelled() { + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + Ok(()) +} + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LegacyConnectionPhase { + Listing, + Inspecting, + ClosingVerifiedSnapshot, +} + +#[cfg(unix)] +#[derive(Debug)] +struct LegacyConnectionInterrupted { + phase: LegacyConnectionPhase, +} + +#[cfg(unix)] +impl LegacyConnectionInterrupted { + fn during(phase: LegacyConnectionPhase) -> Self { + Self { phase } + } +} + +#[cfg(unix)] +impl std::fmt::Display for LegacyConnectionInterrupted { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("legacy server control connection was interrupted") + } +} + +#[cfg(unix)] +impl std::error::Error for LegacyConnectionInterrupted {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReleaseMismatch { + DistributionVersion, + SourceBuild, + TerminalEngine, + Protocol, +} + +impl ReleaseMismatch { + pub(crate) fn message(self, messages: &crate::localization::ServerMessages) -> &'static str { + match self { + Self::DistributionVersion => messages.reason_version, + Self::SourceBuild => messages.reason_source, + Self::TerminalEngine => messages.reason_terminal_engine, + Self::Protocol => messages.reason_protocol, + } + } +} + +fn release_mismatches(server: &ReleaseIdentity, client: &ReleaseIdentity) -> Vec { + let mut mismatches = Vec::new(); + if server.version != client.version { + mismatches.push(ReleaseMismatch::DistributionVersion); + } + if server.build_commit != client.build_commit { + mismatches.push(ReleaseMismatch::SourceBuild); + } + if server.ghostty_commit != client.ghostty_commit { + mismatches.push(ReleaseMismatch::TerminalEngine); + } + if server.protocol != client.protocol { + mismatches.push(ReleaseMismatch::Protocol); + } + mismatches +} + +#[derive(Clone, Debug)] +pub(crate) struct ServerIdentity { + pub release: ReleaseIdentity, + pub pid: u32, + capabilities: HashSet, +} + +impl ServerIdentity { + fn from_protocol_data(data: &Value) -> anyhow::Result { + if data.get("app").and_then(Value::as_str) != Some("cmux-tui") { + anyhow::bail!(crate::localization::catalog().server.endpoint_invalid); + } + let pid = data + .get("pid") + .and_then(Value::as_u64) + .and_then(|pid| u32::try_from(pid).ok()) + .unwrap_or(0); + let capabilities = data + .get("capabilities") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(); + Ok(Self { release: ReleaseIdentity::from_protocol_data(data), pid, capabilities }) + } + + pub(crate) fn supports(&self, capability: &str) -> bool { + self.capabilities.contains(capability) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ServerProbe { + pub identity: ServerIdentity, +} + +impl ServerProbe { + fn inspect_until(reader: &mut TransportReader, deadline: Instant) -> anyhow::Result { + let previous_read_timeout = reader + .get_ref() + .read_timeout() + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.transport_failed))?; + let previous_write_timeout = reader + .get_ref() + .write_timeout() + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.transport_failed))?; + let result = (|| { + set_transport_deadline(reader.get_mut().as_ref(), deadline)?; + write_json_line(reader.get_mut(), &json!({"id": PROBE_REQUEST_ID, "cmd": "identify"})) + .map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.transport_failed) + })?; + let response = read_response_until(reader, PROBE_REQUEST_ID, deadline)?; + if response.get("ok").and_then(Value::as_bool) != Some(true) { + anyhow::bail!(crate::localization::catalog().server.identity_failed); + } + let data = response.get("data").unwrap_or(&Value::Null); + Ok(Self { identity: ServerIdentity::from_protocol_data(data)? }) + })(); + let restored = reader + .get_ref() + .set_read_timeout(previous_read_timeout) + .and_then(|()| reader.get_ref().set_write_timeout(previous_write_timeout)); + match result { + Ok(probe) => match restored { + Ok(()) => Ok(probe), + // A server may close immediately after its complete identity + // response. macOS then rejects timeout socket options with + // EINVAL even though the response remains authoritative. + Err(error) if closed_peer_timeout_error(&error) => Ok(probe), + Err(_) => { + Err(anyhow::anyhow!(crate::localization::catalog().server.transport_failed)) + } + }, + Err(error) => { + let _ = restored; + Err(error) + } + } + } + + fn connect_until( + path: &Path, + deadline: Instant, + ) -> anyhow::Result<(Self, TransportReader, Option)> { + let stream = transport::connect_until(path, deadline) + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.connect_failed))?; + let peer_process_id = stream.peer_process_id().ok().flatten(); + set_transport_deadline(stream.as_ref(), deadline)?; + let mut reader = BufReader::new(stream); + let probe = Self::inspect_until(&mut reader, deadline)?; + Ok((probe, reader, peer_process_id)) + } + + pub(crate) fn is_compatible(&self) -> bool { + self.mismatches().is_empty() + } + + pub(crate) fn mismatches(&self) -> Vec { + release_mismatches(&self.identity.release, &ReleaseIdentity::current(PROTOCOL_VERSION)) + } + + pub(crate) fn require_compatible(&self, path: &Path) -> anyhow::Result<()> { + if self.is_compatible() { + return Ok(()); + } + Err(IncompatibleLocalServer { message: incompatible_server_message(&self.identity, path) } + .into()) + } +} + +#[derive(Debug)] +pub(crate) struct IncompatibleLocalServer { + message: String, +} + +impl std::fmt::Display for IncompatibleLocalServer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for IncompatibleLocalServer {} + +pub(crate) struct ServerLifecycle { + path: PathBuf, + probe: ServerProbe, + reader: TransportReader, + peer_process_id: Option, +} + +impl ServerLifecycle { + #[cfg(unix)] + fn connect_until(path: PathBuf, deadline: Instant) -> anyhow::Result { + let (probe, reader, peer_process_id) = ServerProbe::connect_until(&path, deadline)?; + Ok(Self { path, probe, reader, peer_process_id }) + } + + pub(crate) fn from_connected( + path: PathBuf, + identity: &Value, + reader: TransportReader, + ) -> anyhow::Result { + let peer_process_id = reader.get_ref().peer_process_id().ok().flatten(); + let probe = ServerProbe { identity: ServerIdentity::from_protocol_data(identity)? }; + Ok(Self { path, probe, reader, peer_process_id }) + } + + pub(crate) fn stop_legacy(self) -> anyhow::Result<()> { + if self.probe.identity.release.protocol >= PROTOCOL_VERSION { + anyhow::bail!(crate::localization::catalog().server.shutdown_unsupported); + } + self.stop_legacy_server() + } + + #[cfg(unix)] + fn stop_legacy_server(self) -> anyhow::Result<()> { + let process = verified_legacy_process(self.probe.identity.pid, self.peer_process_id)?; + // Preserve the exact socket inode at a private sibling path, then + // remove the public name before starting the detached helper. Existing + // streams stay valid, while no new caller can enter the legacy + // mutation protocol through the advertised path. + let quarantine = LegacySocketQuarantine::acquire(&self.path).map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + drop(self.reader); + run_detached_legacy_stop(quarantine, process) + } + + #[cfg(not(unix))] + fn stop_legacy_server(self) -> anyhow::Result<()> { + anyhow::bail!(crate::localization::catalog().server.shutdown_unsupported) + } + + #[cfg(unix)] + fn close_legacy_surfaces_until_stable( + &mut self, + expected: ProcessIdentity, + captured: &CapturedProcessTree, + deadline: Instant, + ) -> anyhow::Result { + let mut next_request_id = LEGACY_LIST_REQUEST_ID; + let mut consecutive_empty_scans = 0; + let mut owners = Vec::::new(); + let mut owner_ids = HashSet::::new(); + let mut previous_snapshot_completed = false; + for _ in 0..LEGACY_MAX_SCAN_ROUNDS { + ensure_legacy_helper_active()?; + if Instant::now() >= deadline { + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + self.require_exclusive_legacy_client(&mut next_request_id, deadline)?; + let closed = match self.close_legacy_surface_snapshot( + &mut next_request_id, + expected, + captured, + &mut owners, + &mut owner_ids, + deadline, + ) { + Ok(closed) => closed, + Err(error) => { + let Some(phase) = error + .downcast_ref::() + .map(|error| error.phase) + else { + return Err(error); + }; + let verified_exit = phase == LegacyConnectionPhase::ClosingVerifiedSnapshot + || (phase == LegacyConnectionPhase::Listing && previous_snapshot_completed); + if verified_exit && legacy_server_retired(expected, &self.path)? { + return Ok(LegacySurfaceCleanup { owners, frozen_server: None }); + } + if let Err(reconnect_error) = self.reconnect_legacy_server(expected, deadline) { + if verified_exit + && wait_for_legacy_server_retirement_until( + expected, &self.path, deadline, + )? + { + return Ok(LegacySurfaceCleanup { owners, frozen_server: None }); + } + return Err(reconnect_error); + } + consecutive_empty_scans = 0; + previous_snapshot_completed = false; + continue; + } + }; + previous_snapshot_completed = true; + if closed == 0 { + self.require_exclusive_legacy_client(&mut next_request_id, deadline)?; + consecutive_empty_scans += 1; + if consecutive_empty_scans == LEGACY_STABLE_EMPTY_SCANS { + // Stop the verified server before accepting the final + // empty snapshot. The frozen tree records every remaining + // PTY session while no connection handler can create or + // reparent another surface. + let frozen_server = + freeze_process_tree_until(expected, deadline).map_err(|_| { + anyhow::anyhow!( + crate::localization::catalog().server.legacy_cleanup_failed + ) + })?; + return Ok(LegacySurfaceCleanup { owners, frozen_server: Some(frozen_server) }); + } + } else { + consecutive_empty_scans = 0; + } + } + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed) + } + + #[cfg(unix)] + fn require_exclusive_legacy_client( + &mut self, + next_request_id: &mut u64, + deadline: Instant, + ) -> anyhow::Result<()> { + loop { + ensure_legacy_helper_active()?; + if Instant::now() >= deadline { + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + let request_id = take_legacy_request_id(next_request_id)?; + set_transport_deadline(self.reader.get_mut().as_ref(), deadline).map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + write_json_line( + self.reader.get_mut(), + &json!({"id": request_id, "cmd": "list-clients"}), + ) + .map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + let response = + read_response_until(&mut self.reader, request_id, deadline).map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + let clients = response_data(&response)?.as_array().ok_or_else(|| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + let exclusive = clients.len() == 1 + && clients[0].get("self").and_then(Value::as_bool) == Some(true) + && clients[0].get("transport").and_then(Value::as_str) == Some("unix"); + // The public listener name is already quarantined, so the set can + // only drain. The initiating client's EOF cleanup may lag behind + // helper startup; persistent peers still consume the same bounded + // deadline and fail closed. + if exclusive { + return Ok(()); + } + std::thread::sleep( + LEGACY_CLIENT_DRAIN_POLL_INTERVAL + .min(deadline.saturating_duration_since(Instant::now())), + ); + } + } + + #[cfg(unix)] + fn reconnect_legacy_server( + &mut self, + expected: ProcessIdentity, + deadline: Instant, + ) -> anyhow::Result<()> { + let replacement = Self::connect_until(self.path.clone(), deadline).map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + if replacement.probe.identity.supports(SERVER_SHUTDOWN_CAPABILITY) { + anyhow::bail!(crate::localization::catalog().server.legacy_peer_mismatch); + } + let actual = + verified_legacy_process(replacement.probe.identity.pid, replacement.peer_process_id)?; + if actual != expected { + anyhow::bail!(crate::localization::catalog().server.legacy_peer_mismatch); + } + *self = replacement; + Ok(()) + } + + #[cfg(unix)] + fn close_legacy_surface_snapshot( + &mut self, + next_request_id: &mut u64, + expected: ProcessIdentity, + captured: &CapturedProcessTree, + owners: &mut Vec, + owner_ids: &mut HashSet, + deadline: Instant, + ) -> anyhow::Result { + let list_request_id = take_legacy_request_id(next_request_id)?; + set_transport_deadline(self.reader.get_mut().as_ref(), deadline) + .map_err(|_| LegacyConnectionInterrupted::during(LegacyConnectionPhase::Listing))?; + write_json_line( + self.reader.get_mut(), + &json!({"id": list_request_id, "cmd": "list-workspaces"}), + ) + .map_err(|_| LegacyConnectionInterrupted::during(LegacyConnectionPhase::Listing))?; + let response = read_response_until(&mut self.reader, list_request_id, deadline) + .map_err(|_| LegacyConnectionInterrupted::during(LegacyConnectionPhase::Listing))?; + let data = response_data(&response)?; + let surfaces = legacy_surfaces(data)?; + + for surface in &surfaces { + ensure_legacy_helper_active()?; + if surface.kind != "pty" { + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + } + + // Capture every process-backed surface before sending the first + // topology mutation. If the last close also exits the older server, + // the complete verified snapshot remains available for cleanup. + for surface in &surfaces { + ensure_legacy_helper_active()?; + let process_request_id = take_legacy_request_id(next_request_id)?; + set_transport_deadline(self.reader.get_mut().as_ref(), deadline).map_err(|_| { + LegacyConnectionInterrupted::during(LegacyConnectionPhase::Inspecting) + })?; + write_json_line( + self.reader.get_mut(), + &json!({ + "id": process_request_id, + "cmd": "process-info", + "surface": surface.id, + }), + ) + .map_err(|_| LegacyConnectionInterrupted::during(LegacyConnectionPhase::Inspecting))?; + let process_response = + read_response_until(&mut self.reader, process_request_id, deadline).map_err( + |_| LegacyConnectionInterrupted::during(LegacyConnectionPhase::Inspecting), + )?; + let process_data = response_data(&process_response)?; + let pid = process_data + .get("pid") + .and_then(Value::as_u64) + .and_then(|pid| libc::pid_t::try_from(pid).ok()) + .filter(|pid| *pid > 1); + if let Some(pid) = pid { + let owner = capture_process_session(pid, expected, captured, deadline) + .map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })? + .ok_or_else(|| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + if owner_ids.insert(owner.id()) { + owners.push(owner); + } + } else if !surface.dead { + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + } + + for surface in &surfaces { + ensure_legacy_helper_active()?; + let request_id = take_legacy_request_id(next_request_id)?; + set_transport_deadline(self.reader.get_mut().as_ref(), deadline).map_err(|_| { + LegacyConnectionInterrupted::during(LegacyConnectionPhase::ClosingVerifiedSnapshot) + })?; + write_json_line( + self.reader.get_mut(), + &json!({"id": request_id, "cmd": "close-surface", "surface": surface.id}), + ) + .map_err(|_| { + LegacyConnectionInterrupted::during(LegacyConnectionPhase::ClosingVerifiedSnapshot) + })?; + let _response = + read_response_until(&mut self.reader, request_id, deadline).map_err(|_| { + LegacyConnectionInterrupted::during( + LegacyConnectionPhase::ClosingVerifiedSnapshot, + ) + })?; + // Older servers can commit topology removal, then report a late + // runtime-cleanup error. Reconcile every close response against + // subsequent authoritative workspace snapshots. A genuinely + // failed close remains present for all bounded rounds, so the + // server is still never signaled on ambiguity. + } + Ok(surfaces.len()) + } +} + +#[cfg(unix)] +fn take_legacy_request_id(next_request_id: &mut u64) -> anyhow::Result { + let request_id = *next_request_id; + *next_request_id = request_id.checked_add(1).ok_or_else(|| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_too_many_surfaces) + })?; + Ok(request_id) +} + +#[cfg(unix)] +fn run_detached_legacy_stop( + quarantine: LegacySocketQuarantine, + expected: ProcessIdentity, +) -> anyhow::Result<()> { + use std::os::unix::process::CommandExt; + + let reaper = reserve_legacy_helper_reaper()?; + let executable = std::env::current_exe().map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + let mut command = Command::new(executable); + let publication_descriptor = quarantine.publication_guard.inherit_into(&mut command); + command + .arg("__legacy-stop-helper") + .arg(&quarantine.original) + .arg(&quarantine.quarantined) + .arg(expected.pid().to_string()) + .arg(expected.started_at().to_string()) + .arg(quarantine.device.to_string()) + .arg(quarantine.inode.to_string()) + .arg(publication_descriptor.to_string()) + .arg(LEGACY_SHUTDOWN_TIMEOUT.as_millis().to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let helper = cmux_tui_process::spawn(&mut command).map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + let deadline = Instant::now() + LEGACY_SHUTDOWN_TIMEOUT + LEGACY_HELPER_WAIT_MARGIN; + let status = + wait_for_child_until(LegacyHelperChild { child: helper, quarantine }, reaper, deadline)?; + if status.success() { + return Ok(()); + } + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed) +} + +#[cfg(unix)] +fn wait_for_child_until( + helper: LegacyHelperChild, + reaper: LegacyHelperReaperLease, + deadline: Instant, +) -> anyhow::Result { + wait_for_child_until_with(helper, reaper, deadline, Instant::now, std::thread::sleep) +} + +#[cfg(unix)] +fn wait_for_child_until_with( + mut helper: LegacyHelperChild, + reaper: LegacyHelperReaperLease, + deadline: Instant, + mut now: Now, + mut sleep: Sleep, +) -> anyhow::Result +where + Now: FnMut() -> Instant, + Sleep: FnMut(Duration), +{ + loop { + match helper.try_wait() { + Ok(Some(status)) => { + helper.settle(status).map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + return Ok(status); + } + Ok(None) => {} + Err(_) => { + reaper.retain_shutdown_ownership(helper); + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + } + if now() >= deadline { + let pid = libc::pid_t::try_from(helper.id()).ok(); + if let Some(pid) = pid { + // The helper installs a cooperative handler so Rust cleanup + // can thaw any exact process identities currently fenced. + let _ = unsafe { libc::kill(pid, libc::SIGTERM) }; + } + let cancel_deadline = now() + LEGACY_HELPER_CANCEL_MARGIN; + while now() < cancel_deadline { + match helper.try_wait() { + Ok(Some(status)) => { + helper.settle(status).map_err(|_| { + anyhow::anyhow!( + crate::localization::catalog().server.legacy_cleanup_failed + ) + })?; + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + Ok(None) => {} + Err(_) => { + reaper.retain_shutdown_ownership(helper); + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + } + sleep( + cancel_deadline + .saturating_duration_since(now()) + .min(LEGACY_HELPER_POLL_INTERVAL), + ); + } + reaper.retain_shutdown_ownership(helper); + anyhow::bail!(crate::localization::catalog().server.legacy_cleanup_failed); + } + sleep(deadline.saturating_duration_since(now()).min(LEGACY_HELPER_POLL_INTERVAL)); + } +} + +#[cfg(unix)] +pub(crate) fn run_legacy_stop_helper(args: &[String]) -> anyhow::Result<()> { + install_legacy_helper_signal_handler(); + let [ + original_path, + quarantined_path, + expected_pid, + expected_started_at, + device, + inode, + publication_descriptor, + timeout_ms, + ] = args + else { + anyhow::bail!(crate::localization::catalog().server.shutdown_unsupported); + }; + let expected_pid = + expected_pid.parse::().ok().filter(|pid| *pid > 1).ok_or_else(|| { + anyhow::anyhow!(crate::localization::catalog().server.shutdown_unsupported) + })?; + let expected_started_at = expected_started_at + .parse::() + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.shutdown_unsupported))?; + let device = device + .parse::() + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.shutdown_unsupported))?; + let inode = inode + .parse::() + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.shutdown_unsupported))?; + let publication_descriptor = publication_descriptor + .parse::() + .ok() + .filter(|descriptor| *descriptor > libc::STDERR_FILENO) + .ok_or_else(|| { + anyhow::anyhow!(crate::localization::catalog().server.shutdown_unsupported) + })?; + let timeout = timeout_ms + .parse::() + .ok() + .map(Duration::from_millis) + .filter(|timeout| !timeout.is_zero() && *timeout <= LEGACY_SHUTDOWN_TIMEOUT) + .ok_or_else(|| { + anyhow::anyhow!(crate::localization::catalog().server.shutdown_unsupported) + })?; + let deadline = Instant::now() + timeout; + ensure_legacy_helper_active()?; + // SAFETY: this private exec mode receives the unique child copy created by + // `PublicationGuard::inherit_into` and never aliases that raw fd. + let publication_guard = unsafe { + PublicationGuard::adopt_inherited( + Path::new(original_path), + publication_descriptor, + deadline, + ) + } + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed))?; + let mut quarantine = LegacySocketQuarantine::adopt( + PathBuf::from(original_path), + PathBuf::from(quarantined_path), + device, + inode, + publication_guard, + ) + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed))?; + let expected = ProcessIdentity::from_parts(expected_pid, expected_started_at); + let result = (|| { + let mut lifecycle = + ServerLifecycle::connect_until(quarantine.path().to_path_buf(), deadline)?; + if lifecycle.probe.identity.supports(SERVER_SHUTDOWN_CAPABILITY) { + anyhow::bail!(crate::localization::catalog().server.legacy_peer_mismatch); + } + let actual = + verified_legacy_process(lifecycle.probe.identity.pid, lifecycle.peer_process_id)?; + if actual != expected { + anyhow::bail!(crate::localization::catalog().server.legacy_peer_mismatch); + } + let captured = capture_legacy_process_tree(actual, deadline)?; + + ensure_legacy_helper_active()?; + let cleanup = + lifecycle.close_legacy_surfaces_until_stable(actual, &captured, deadline).map_err( + |_| anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed), + )?; + drop(captured); + for owner in cleanup.owners { + ensure_legacy_helper_active()?; + owner.terminate_until(deadline).map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + } + if let Some(server) = cleanup.frozen_server { + ensure_legacy_helper_active()?; + server.terminate_until(deadline).map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_signal_failed) + })?; + } + wait_for_disconnect_until(&mut lifecycle.reader, &lifecycle.path, deadline) + })(); + match result { + Ok(()) => { + quarantine.commit().map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + Ok(()) + } + Err(error) => { + quarantine.restore().map_err(|_| { + anyhow::anyhow!(crate::localization::catalog().server.legacy_cleanup_failed) + })?; + Err(error) + } + } +} + +#[cfg(unix)] +fn verified_legacy_process( + reported_pid: u32, + peer_process_id: Option, +) -> anyhow::Result { + let messages = &crate::localization::catalog().server; + let peer_process_id = + peer_process_id.ok_or_else(|| anyhow::anyhow!(messages.legacy_peer_unavailable))?; + if reported_pid != peer_process_id { + anyhow::bail!(messages.legacy_peer_mismatch); + } + let current_pid = libc::pid_t::try_from(std::process::id()).ok(); + let pid = libc::pid_t::try_from(reported_pid) + .ok() + .filter(|pid| *pid > 1 && Some(*pid) != current_pid) + .ok_or_else(|| anyhow::anyhow!(messages.shutdown_unsupported))?; + ProcessIdentity::capture(pid) + .map_err(|_| anyhow::anyhow!(messages.legacy_peer_unavailable))? + .ok_or_else(|| anyhow::anyhow!(messages.legacy_peer_mismatch)) +} + +#[cfg(unix)] +fn legacy_process_exited(expected: ProcessIdentity) -> anyhow::Result { + let messages = &crate::localization::catalog().server; + match ProcessIdentity::capture(expected.pid()) { + Ok(None) => Ok(true), + Ok(Some(current)) if current == expected => Ok(false), + Ok(Some(_)) => Err(anyhow::anyhow!(messages.legacy_peer_mismatch)), + Err(_) => Err(anyhow::anyhow!(messages.legacy_peer_unavailable)), + } +} + +#[cfg(unix)] +fn legacy_server_retired(expected: ProcessIdentity, _path: &Path) -> anyhow::Result { + legacy_process_exited(expected) +} + +#[cfg(unix)] +fn wait_for_legacy_server_retirement_until( + expected: ProcessIdentity, + path: &Path, + deadline: Instant, +) -> anyhow::Result { + loop { + if legacy_server_retired(expected, path)? { + return Ok(true); + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Ok(false); + }; + std::thread::sleep(remaining.min(LEGACY_HELPER_POLL_INTERVAL)); + } +} + +#[cfg(all(unix, test))] +fn terminate_legacy_process_tree(process: ProcessIdentity) -> anyhow::Result<()> { + terminate_process_tree(process) + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.legacy_signal_failed)) +} + +#[cfg(unix)] +fn capture_legacy_process_tree( + process: ProcessIdentity, + deadline: Instant, +) -> anyhow::Result { + capture_process_tree_until(process, deadline) + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.legacy_signal_failed)) +} + +pub(crate) fn validate_local_identity(data: &Value, path: &Path) -> anyhow::Result<()> { + let probe = ServerProbe { identity: ServerIdentity::from_protocol_data(data)? }; + probe.require_compatible(path) +} + +pub(crate) fn incompatible_server_message(identity: &ServerIdentity, path: &Path) -> String { + let messages = &crate::localization::catalog().server; + let client = ReleaseIdentity::current(PROTOCOL_VERSION); + let reasons = release_mismatches(&identity.release, &client) + .into_iter() + .map(|reason| reason.message(messages)) + .collect::>() + .join(messages.reason_separator); + let launcher = current_launcher_command(); + let command = format!("{launcher} server stop --socket {}", shell_quote(path)); + let replay = current_replay_command(); + let restart = format!( + "{}{}{}{}{}", + messages.restart_before_command, + command, + messages.restart_between_commands, + replay, + messages.restart_after_command + ); + format!( + "{}\n\n{}: v{} {} {}\n{}: v{} {} {}\n{}: {}\n\n{}\n{}\n{}", + messages.incompatible_local_server, + messages.server_label, + identity.release.version, + messages.protocol_label, + identity.release.protocol, + messages.client_label, + client.version, + messages.protocol_label, + client.protocol, + messages.reason_label, + reasons, + messages.stop_to_use, + messages.stopping_exits_panes, + restart, + ) +} + +fn current_launcher_command() -> String { + let override_command = std::env::var_os(LAUNCHER_COMMAND_ENV); + let argv0 = std::env::args_os().next(); + launcher_command_from(override_command.as_deref(), argv0.as_deref()) +} + +fn current_replay_command() -> String { + replay_command_from(current_launcher_command(), std::env::args_os().skip(1)) +} + +fn replay_command_from(command: String, arguments: I) -> String +where + I: IntoIterator, + S: AsRef, +{ + // Arguments can contain terminal input, child commands, or credentials. + // Shell history already retains them for the user; diagnostics identify only + // the launcher that should rerun the previous command. + drop(arguments); + command +} + +fn launcher_command_from(override_command: Option<&OsStr>, argv0: Option<&OsStr>) -> String { + if let Some(command) = + override_command.and_then(OsStr::to_str).map(str::trim).filter(|command| { + !command.is_empty() + && command.len() <= MAX_LAUNCHER_COMMAND_BYTES + && !command.chars().any(char::is_control) + }) + { + return command.to_string(); + } + + argv0 + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|path| shell_quote(&path)) + .unwrap_or_else(|| "cmux-tui".to_string()) +} + +pub(crate) fn write_json_line(writer: &mut dyn Write, value: &Value) -> std::io::Result<()> { + serde_json::to_writer(&mut *writer, value).map_err(std::io::Error::other)?; + writer.write_all(b"\n") +} + +fn set_transport_deadline(stream: &dyn transport::Stream, deadline: Instant) -> anyhow::Result<()> { + set_transport_deadline_raw(stream, deadline) + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.transport_failed)) +} + +fn set_transport_deadline_raw( + stream: &dyn transport::Stream, + deadline: Instant, +) -> std::io::Result<()> { + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::TimedOut, "server lifecycle deadline expired") + })?; + stream + .set_read_timeout(Some(remaining)) + .and_then(|()| stream.set_write_timeout(Some(remaining))) +} + +fn closed_peer_timeout_error(error: &std::io::Error) -> bool { + error.raw_os_error() == Some(libc::EINVAL) + || matches!( + error.kind(), + std::io::ErrorKind::InvalidInput + | std::io::ErrorKind::NotConnected + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionReset + ) +} + +fn read_response_until( + reader: &mut TransportReader, + request_id: u64, + deadline: Instant, +) -> anyhow::Result { + read_matching_response_until(reader, request_id, false, deadline) +} + +fn read_matching_response_until( + reader: &mut TransportReader, + request_id: u64, + accept_unidentified_error: bool, + deadline: Instant, +) -> anyhow::Result { + let mut line = Vec::new(); + loop { + match read_bounded_lifecycle_line_until(reader, &mut line, deadline) { + Ok(false) => anyhow::bail!(crate::localization::catalog().server.response_closed), + Ok(true) => {} + Err(LifecycleLineError::Invalid) => { + anyhow::bail!(crate::localization::catalog().server.response_invalid) + } + Err(LifecycleLineError::Deadline | LifecycleLineError::Transport) => { + anyhow::bail!(crate::localization::catalog().server.transport_failed) + } + } + let value: Value = serde_json::from_slice(&line) + .map_err(|_| anyhow::anyhow!(crate::localization::catalog().server.response_invalid))?; + if value.get("event").is_some() { + continue; + } + let response_id = value.get("id").and_then(Value::as_u64); + if response_id == Some(request_id) + || (accept_unidentified_error + && response_id.is_none() + && value.get("ok").and_then(Value::as_bool) == Some(false)) + { + return Ok(value); + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LifecycleLineError { + Deadline, + Invalid, + Transport, +} + +fn read_bounded_lifecycle_line_until( + reader: &mut TransportReader, + line: &mut Vec, + deadline: Instant, +) -> Result { + line.clear(); + loop { + if Instant::now() >= deadline { + return Err(LifecycleLineError::Deadline); + } + if let Err(error) = set_transport_deadline_raw(reader.get_mut().as_ref(), deadline) + && !closed_peer_timeout_error(&error) + { + return Err( + if Instant::now() >= deadline || error.kind() == std::io::ErrorKind::TimedOut { + LifecycleLineError::Deadline + } else { + LifecycleLineError::Transport + }, + ); + } + let available = match reader.fill_buf() { + Ok(available) => available, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + if Instant::now() >= deadline { + return Err(LifecycleLineError::Deadline); + } + continue; + } + Err(_) => return Err(LifecycleLineError::Transport), + }; + if available.is_empty() { + return Ok(!line.is_empty()); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let payload_bytes = newline.unwrap_or(available.len()); + if payload_bytes > MAX_LIFECYCLE_RESPONSE_BYTES.saturating_sub(line.len()) { + return Err(LifecycleLineError::Invalid); + } + line.extend_from_slice(&available[..payload_bytes]); + let consumed = payload_bytes + usize::from(newline.is_some()); + reader.consume(consumed); + if newline.is_some() { + return Ok(true); + } + } +} + +#[cfg(unix)] +fn response_data(response: &Value) -> anyhow::Result<&Value> { + if response.get("ok").and_then(Value::as_bool) != Some(true) { + anyhow::bail!(crate::localization::catalog().server.legacy_list_failed); + } + Ok(response.get("data").unwrap_or(&Value::Null)) +} + +#[cfg(unix)] +struct LegacySurface { + id: u64, + kind: String, + dead: bool, +} + +#[cfg(unix)] +fn legacy_surfaces(data: &Value) -> anyhow::Result> { + let mut surfaces = std::collections::HashMap::::new(); + for workspace in data.get("workspaces").and_then(Value::as_array).into_iter().flatten() { + for screen in workspace.get("screens").and_then(Value::as_array).into_iter().flatten() { + for pane in screen.get("panes").and_then(Value::as_array).into_iter().flatten() { + for tab in pane.get("tabs").and_then(Value::as_array).into_iter().flatten() { + if let Some(surface) = tab.get("surface").and_then(Value::as_u64) { + let kind = tab.get("kind").and_then(Value::as_str).ok_or_else(|| { + anyhow::anyhow!( + crate::localization::catalog().server.legacy_cleanup_failed + ) + })?; + let dead = tab.get("dead").and_then(Value::as_bool).unwrap_or(false); + if let Some(previous) = surfaces.insert(surface, (kind.to_string(), dead)) + && previous != (kind.to_string(), dead) + { + anyhow::bail!( + crate::localization::catalog().server.legacy_cleanup_failed + ); + } + } + } + } + } + } + let mut surfaces = surfaces + .into_iter() + .map(|(id, (kind, dead))| LegacySurface { id, kind, dead }) + .collect::>(); + surfaces.sort_by_key(|surface| surface.id); + Ok(surfaces) +} + +#[cfg(all(unix, test))] +fn legacy_surface_ids(data: &Value) -> Vec { + legacy_surfaces(data).unwrap().into_iter().map(|surface| surface.id).collect() +} + +fn wait_for_disconnect_until( + reader: &mut TransportReader, + path: &Path, + deadline: Instant, +) -> anyhow::Result<()> { + let mut line = Vec::new(); + loop { + match read_bounded_lifecycle_line_until(reader, &mut line, deadline) { + Ok(false) => return Ok(()), + Ok(true) => {} + Err(LifecycleLineError::Deadline) => { + if !path.exists() { + return Ok(()); + } + anyhow::bail!(crate::localization::catalog().server.shutdown_timed_out); + } + Err(LifecycleLineError::Transport) => { + // A transport failure on the already-authenticated control + // stream is the disconnect this post-ACK wait is looking for. + // Reconnecting here can block on a saturated accept queue and + // cannot strengthen the identity already proven by the stream. + return Ok(()); + } + Err(LifecycleLineError::Invalid) => { + anyhow::bail!(crate::localization::catalog().server.transport_failed); + } + } + } +} + +fn shell_quote(path: &Path) -> String { + #[cfg(unix)] + { + use std::fmt::Write as _; + use std::os::unix::ffi::OsStrExt as _; + + let bytes = path.as_os_str().as_bytes(); + if std::str::from_utf8(bytes).is_err() { + let mut quoted = String::from("$'"); + for byte in bytes.iter().copied() { + match byte { + b'\'' => quoted.push_str("\\'"), + b'\\' => quoted.push_str("\\\\"), + b' '..=b'~' => quoted.push(char::from(byte)), + _ => write!(quoted, "\\{byte:03o}").expect("writing to String cannot fail"), + } + } + quoted.push('\''); + return quoted; + } + } + let value = path.display().to_string(); + if !value.is_empty() + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '/' | '.' | '_' | '-') + }) + { + value + } else { + format!("'{}'", value.replace('\'', "'\"'\"'")) + } +} + +#[cfg(test)] +mod tests { + use std::io::{self, Read}; + use std::net::Shutdown; + #[cfg(unix)] + use std::process::{Command, Stdio}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + #[cfg(unix)] + use std::sync::mpsc; + + #[cfg(unix)] + use cmux_tui_core::{Mux, SurfaceOptions}; + + use super::*; + + struct FloodStream { + remaining_chunks: usize, + reads: Arc, + } + + impl Read for FloodStream { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.remaining_chunks == 0 { + return Ok(0); + } + self.remaining_chunks -= 1; + self.reads.fetch_add(1, Ordering::AcqRel); + buffer.fill(b'x'); + Ok(buffer.len()) + } + } + + impl Write for FloodStream { + fn write(&mut self, buffer: &[u8]) -> io::Result { + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl transport::Stream for FloodStream { + fn try_clone_box(&self) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "test stream is not cloneable")) + } + + fn read_timeout(&self) -> io::Result> { + Ok(None) + } + + fn set_read_timeout(&self, _timeout: Option) -> io::Result<()> { + Ok(()) + } + + fn write_timeout(&self) -> io::Result> { + Ok(None) + } + + fn set_write_timeout(&self, _timeout: Option) -> io::Result<()> { + Ok(()) + } + + fn shutdown(&self, _how: Shutdown) -> io::Result<()> { + Ok(()) + } + } + + #[cfg(unix)] + fn quarantined_test_listener( + name: &str, + ) -> (std::os::unix::net::UnixListener, LegacySocketQuarantine, PathBuf) { + let path = PathBuf::from("/tmp").join(format!( + "{name}-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let quarantine = LegacySocketQuarantine::acquire(&path).unwrap(); + (listener, quarantine, path) + } + + #[cfg(unix)] + fn remove_quarantined_test_path(path: &Path) { + let _ = std::fs::remove_file(path); + let mut lock_name = std::ffi::OsString::from("."); + lock_name.push(path.file_name().unwrap()); + lock_name.push(".publish.lock"); + let _ = std::fs::remove_file(path.with_file_name(lock_name)); + } + + #[cfg(unix)] + #[test] + fn legacy_quarantine_blocks_replacement_publication() { + let directory = std::env::temp_dir().join(format!( + "cq-publication-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("server.sock"); + let publication_lock = directory.join(".server.sock.publish.lock"); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let mut quarantine = LegacySocketQuarantine::acquire(&path).unwrap(); + let (sender, receiver) = mpsc::channel(); + let publication_path = path.clone(); + let publisher = std::thread::spawn(move || { + let mux = Mux::new("quarantine-publication-test", SurfaceOptions::default()); + let result = cmux_tui_core::server::serve_owned(mux, Some(publication_path)); + sender.send(result).unwrap(); + }); + + match receiver.recv_timeout(Duration::from_millis(250)) { + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(error) => panic!("replacement publication channel failed: {error}"), + Ok(result) => { + if let Ok(publication) = result { + publication.cleanup(); + } + quarantine.restore().unwrap(); + drop(quarantine); + drop(listener); + let _ = std::fs::remove_file(&path); + publisher.join().unwrap(); + let _ = std::fs::remove_file(&publication_lock); + std::fs::remove_dir(&directory).unwrap(); + panic!("replacement server published while the legacy socket was quarantined"); + } + } + + quarantine.restore().unwrap(); + drop(quarantine); + let result = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("replacement publication did not resume after quarantine ownership ended"); + assert!(result.is_err(), "replacement publication replaced the restored live socket"); + publisher.join().unwrap(); + drop(listener); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(publication_lock).unwrap(); + std::fs::remove_dir(directory).unwrap(); + } + + #[test] + fn mismatch_message_contains_both_release_identities_and_stop_command() { + let identity = ServerIdentity { + release: ReleaseIdentity { + version: "0.1.0-old".to_string(), + build_commit: None, + ghostty_commit: None, + protocol: PROTOCOL_VERSION - 1, + }, + pid: 42, + capabilities: HashSet::new(), + }; + + let message = incompatible_server_message(&identity, Path::new("/tmp/test socket")); + + assert!(message.contains(&format!("server: v0.1.0-old protocol {}", PROTOCOL_VERSION - 1))); + assert!(message.contains("client: v")); + assert!(message.contains("reason: distribution version differs")); + assert!(message.contains("source build differs")); + assert!(message.contains("terminal engine build differs")); + assert!(message.contains("protocol differs")); + let launcher = current_launcher_command(); + assert!( + message.contains(&format!("{launcher} server stop --socket '/tmp/test socket'")), + "{message}" + ); + assert!( + message.contains(&format!("then rerun the previous command using `{launcher}`.")), + "{message}" + ); + assert!(message.contains("Stopping exits pane processes.")); + } + + #[cfg(unix)] + #[test] + fn shell_quote_preserves_non_utf8_socket_paths() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt as _; + + let quoted = shell_quote(Path::new(OsStr::from_bytes(b"/tmp/test-\xff.sock"))); + + assert_eq!(quoted, "$'/tmp/test-\\377.sock'"); + assert!(!quoted.contains('\u{fffd}')); + } + + #[test] + fn replayable_launcher_override_survives_one_shot_package_shims() { + let argv0 = OsStr::new("/tmp/ephemeral/bin/cmux"); + + assert_eq!(launcher_command_from(Some(OsStr::new("npx cmux")), Some(argv0)), "npx cmux"); + assert_eq!(launcher_command_from(Some(OsStr::new("uvx cmux")), Some(argv0)), "uvx cmux"); + assert_eq!( + launcher_command_from(Some(OsStr::new("bad\ncommand")), Some(argv0)), + "/tmp/ephemeral/bin/cmux" + ); + } + + #[test] + fn replay_command_omits_payload_arguments_from_guidance() { + assert_eq!( + replay_command_from("cmux-tui".to_string(), std::iter::empty::<&str>()), + "cmux-tui" + ); + let secret = "cmux-review-secret-do-not-print"; + let replay = replay_command_from( + "cmux-tui".to_string(), + ["--socket", "/tmp/cmux.sock", "send", "--text", secret], + ); + assert_eq!(replay, "cmux-tui"); + assert!(!replay.contains(secret)); + } + + #[cfg(unix)] + #[test] + fn lifecycle_connect_deadline_expires_before_socket_resolution() { + let path = PathBuf::from("/tmp").join(format!( + "cmux-tui-connect-deadline-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let started = Instant::now(); + let result = ServerProbe::connect_until(&path, started - Duration::from_millis(1)); + let elapsed = started.elapsed(); + + assert!(result.is_err(), "missing lifecycle socket unexpectedly connected"); + assert!( + elapsed < Duration::from_millis(50), + "expired lifecycle connect entered socket resolution: {elapsed:?}" + ); + } + + #[cfg(unix)] + #[test] + fn legacy_helper_timeout_retains_reap_ownership() { + use std::io::BufRead as _; + + let _serial = LEGACY_HELPER_REAPER_TEST_LOCK.lock().unwrap(); + let (listener, quarantine, path) = quarantined_test_listener("cq-reap"); + let mut helper = Command::new("/bin/sh") + .arg("-c") + .arg("trap '' TERM; printf 'ready\\n'; read _") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut ready = String::new(); + BufReader::new(helper.stdout.take().unwrap()).read_line(&mut ready).unwrap(); + assert_eq!(ready, "ready\n"); + let helper_pid = libc::pid_t::try_from(helper.id()).unwrap(); + let logical_start = Instant::now(); + let logical_now = Arc::new(Mutex::new(logical_start)); + let read_clock = logical_now.clone(); + let advance_clock = logical_now.clone(); + let baseline = active_legacy_helper_reapers_for_test(); + let reaper = reserve_legacy_helper_reaper().unwrap(); + + let error = wait_for_child_until_with( + LegacyHelperChild { child: helper, quarantine }, + reaper, + logical_start + Duration::from_millis(50), + move || *read_clock.lock().unwrap(), + move |duration| { + let mut clock = advance_clock.lock().unwrap(); + *clock = clock.checked_add(duration).unwrap(); + }, + ) + .unwrap_err(); + let logical_elapsed = logical_now.lock().unwrap().duration_since(logical_start); + // SAFETY: helper_pid names this exact test-owned child. + unsafe { + libc::kill(helper_pid, libc::SIGKILL); + } + assert!(wait_for_active_legacy_helper_reapers_for_test( + baseline, + Instant::now() + Duration::from_secs(1) + )); + let mut status = 0; + // SAFETY: this probes whether another owner retained and reaped the + // exact test child after wait_for_child_until returned. + let reaped = unsafe { libc::waitpid(helper_pid, &raw mut status, libc::WNOHANG) }; + let reaped_error = (reaped == -1).then(io::Error::last_os_error); + if reaped == 0 { + // SAFETY: the child is still owned by this test process. + unsafe { + libc::kill(helper_pid, libc::SIGKILL); + libc::waitpid(helper_pid, &raw mut status, 0); + } + } + + assert_eq!(error.to_string(), crate::localization::catalog().server.legacy_cleanup_failed); + assert!( + logical_elapsed + <= Duration::from_millis(50).saturating_add(LEGACY_HELPER_CANCEL_MARGIN), + "helper wait exceeded its logical bound: {logical_elapsed:?}" + ); + assert_eq!(reaped, -1, "timed-out helper lost its reaping owner"); + assert_eq!(reaped_error.and_then(|error| error.raw_os_error()), Some(libc::ECHILD)); + drop(listener); + remove_quarantined_test_path(&path); + } + + #[cfg(unix)] + #[test] + fn legacy_quarantine_never_unlinks_a_replacement_socket() { + use std::os::unix::net::{UnixListener, UnixStream}; + + let path = PathBuf::from("/tmp").join(format!( + "cq-race-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let original = UnixListener::bind(&path).unwrap(); + let mut replacement = None; + + let quarantine = LegacySocketQuarantine::acquire_with_transfer_hook(&path, || { + std::fs::remove_file(&path).unwrap(); + replacement = Some(UnixListener::bind(&path).unwrap()); + }); + let rejected_replacement = quarantine.is_err(); + let replacement_still_reachable = UnixStream::connect(&path).is_ok(); + + drop(quarantine); + drop(replacement); + drop(original); + remove_quarantined_test_path(&path); + + assert!(rejected_replacement, "quarantine accepted a replacement socket"); + assert!( + replacement_still_reachable, + "quarantine unlinked the replacement socket while transferring the verified inode" + ); + } + + #[cfg(unix)] + #[test] + fn legacy_helper_timeout_keeps_the_socket_quarantined_until_helper_exit() { + use std::io::BufRead as _; + use std::os::unix::net::UnixStream; + + let _serial = LEGACY_HELPER_REAPER_TEST_LOCK.lock().unwrap(); + let (listener, quarantine, path) = quarantined_test_listener("cq-help"); + let mut helper = Command::new("/bin/sh") + .arg("-c") + .arg("trap '' TERM; printf 'ready\\n'; read _") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut ready = String::new(); + BufReader::new(helper.stdout.take().unwrap()).read_line(&mut ready).unwrap(); + assert_eq!(ready, "ready\n"); + let helper_pid = libc::pid_t::try_from(helper.id()).unwrap(); + let baseline = active_legacy_helper_reapers_for_test(); + let reaper = reserve_legacy_helper_reaper().unwrap(); + + let error = wait_for_child_until( + LegacyHelperChild { child: helper, quarantine }, + reaper, + Instant::now() + Duration::from_millis(50), + ) + .unwrap_err(); + let hidden_while_helper_alive = UnixStream::connect(&path).is_err(); + // SAFETY: helper_pid names this exact test-owned child. + unsafe { + libc::kill(helper_pid, libc::SIGKILL); + } + let deadline = Instant::now() + Duration::from_secs(1); + assert!(wait_for_active_legacy_helper_reapers_for_test(baseline, deadline)); + let restored_after_helper_exit = UnixStream::connect(&path).is_ok(); + + drop(listener); + remove_quarantined_test_path(&path); + + assert_eq!(error.to_string(), crate::localization::catalog().server.legacy_cleanup_failed); + assert!( + hidden_while_helper_alive, + "timed-out helper released its admission fence before exit" + ); + assert!( + restored_after_helper_exit, + "helper reaper did not restore the socket after exact helper exit" + ); + } + + #[cfg(unix)] + #[test] + fn legacy_reaper_releases_children_with_lost_wait_ownership() { + let _serial = LEGACY_HELPER_REAPER_TEST_LOCK.lock().unwrap(); + let (listener, quarantine, path) = quarantined_test_listener("cq-lost"); + let child = Command::new("/usr/bin/true") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let child_pid = libc::pid_t::try_from(child.id()).unwrap(); + let mut status = 0; + // SAFETY: child_pid is this test's exact child. Reaping it here + // simulates another library consuming the wait status first. + assert_eq!(unsafe { libc::waitpid(child_pid, &raw mut status, 0) }, child_pid); + + let baseline = active_legacy_helper_reapers_for_test(); + let reaper = reserve_legacy_helper_reaper().unwrap(); + assert_eq!(active_legacy_helper_reapers_for_test(), baseline + 1); + reaper.retain_shutdown_ownership(LegacyHelperChild { child, quarantine }); + + let deadline = Instant::now() + Duration::from_millis(250); + assert!(wait_for_active_legacy_helper_reapers_for_test(baseline, deadline)); + + assert_eq!( + active_legacy_helper_reapers_for_test(), + baseline, + "legacy reaper retained a child after the kernel reported no wait ownership" + ); + assert!( + std::os::unix::net::UnixStream::connect(&path).is_ok(), + "lost wait ownership did not release the admission fence after helper exit" + ); + drop(listener); + remove_quarantined_test_path(&path); + } + + #[cfg(unix)] + #[test] + fn legacy_stop_terminates_only_the_identified_process() { + let mut child = + Command::new("yes").stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + + let process = verified_legacy_process(child.id(), Some(child.id())).unwrap(); + terminate_legacy_process_tree(process).unwrap(); + + assert!(!child.wait().unwrap().success()); + } + + #[cfg(unix)] + #[test] + fn legacy_stop_rejects_a_reported_pid_that_is_not_the_socket_peer() { + let error = verified_legacy_process(41, Some(42)).unwrap_err(); + + assert_eq!(error.to_string(), crate::localization::catalog().server.legacy_peer_mismatch); + } + + #[cfg(unix)] + #[test] + fn legacy_surface_ids_follow_the_workspace_tree() { + let data = json!({ + "workspaces": [{ + "screens": [{ + "panes": [ + {"tabs": [ + {"surface": 11, "kind": "pty"}, + {"surface": 12, "kind": "pty"} + ]}, + {"tabs": [{"surface": 13, "kind": "pty"}]}, + ], + }], + }], + }); + + assert_eq!(legacy_surface_ids(&data), [11, 12, 13]); + } + + #[cfg(unix)] + #[test] + fn response_deadline_overrides_a_longer_stream_timeout() { + let (client, _server) = std::os::unix::net::UnixStream::pair().unwrap(); + client.set_read_timeout(Some(Duration::from_millis(500))).unwrap(); + let mut reader = BufReader::new(Box::new(client) as Box); + let started = Instant::now(); + + let error = read_matching_response_until( + &mut reader, + 7, + false, + Instant::now() + Duration::from_millis(50), + ) + .unwrap_err(); + + assert!( + started.elapsed() < Duration::from_millis(250), + "response deadline was hidden by the stream timeout: {:?}", + started.elapsed() + ); + assert_eq!(error.to_string(), crate::localization::catalog().server.transport_failed); + } + + #[test] + fn response_reader_rejects_an_unterminated_frame_at_its_size_limit() { + const CHUNK_BYTES: usize = 1024 * 1024; + let reads = Arc::new(AtomicUsize::new(0)); + let stream = FloodStream { remaining_chunks: 20, reads: reads.clone() }; + let mut reader = + BufReader::with_capacity(CHUNK_BYTES, Box::new(stream) as Box); + + let error = read_matching_response_until( + &mut reader, + 7, + false, + Instant::now() + Duration::from_secs(1), + ) + .unwrap_err(); + + assert_eq!(error.to_string(), crate::localization::catalog().server.response_invalid); + assert!( + reads.load(Ordering::Acquire) <= 17, + "response reader consumed an unbounded unterminated frame" + ); + } +} diff --git a/cmux-tui/crates/cmux-tui/src/server_lifecycle/legacy_process.rs b/cmux-tui/crates/cmux-tui/src/server_lifecycle/legacy_process.rs new file mode 100644 index 000000000000..06085cc5ef3d --- /dev/null +++ b/cmux-tui/crates/cmux-tui/src/server_lifecycle/legacy_process.rs @@ -0,0 +1,1118 @@ +use std::collections::HashSet; +use std::io; +use std::time::{Duration, Instant}; + +use cmux_tui_core::process_session::StableProcessHandle; + +const PROCESS_TREE_MAX_ROUNDS: usize = 64; +const PROCESS_TREE_RETRY_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(test)] +const PROCESS_TREE_RETRY_TIMEOUT: Duration = Duration::from_secs(1); + +#[cfg(test)] +thread_local! { + static STABLE_HANDLE_CAPTURE_BUDGET: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +fn ensure_helper_active() -> io::Result<()> { + if super::legacy_helper_cancelled() { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "legacy cleanup helper was cancelled", + )); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct ProcessIdentity { + pid: libc::pid_t, + started_at: u128, +} + +impl ProcessIdentity { + pub(super) fn from_parts(pid: libc::pid_t, started_at: u128) -> Self { + Self { pid, started_at } + } + + pub(super) fn pid(self) -> libc::pid_t { + self.pid + } + + pub(super) fn started_at(self) -> u128 { + self.started_at + } + + pub(super) fn capture(pid: libc::pid_t) -> io::Result> { + process_snapshot(pid).map(|snapshot| snapshot.map(|snapshot| snapshot.identity)) + } + + #[cfg(test)] + fn signal(self, signal: libc::c_int) -> io::Result { + let Some(process) = self.stable_handle()? else { + return Ok(ExactSignalResult::Gone); + }; + if process.signal(signal)? { + Ok(ExactSignalResult::Signaled) + } else { + Ok(ExactSignalResult::Gone) + } + } + + fn stable_handle(self) -> io::Result> { + #[cfg(test)] + STABLE_HANDLE_CAPTURE_BUDGET.with(|budget| { + if let Some(remaining) = budget.get() { + if remaining == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "forced stable-handle capture failure", + )); + } + budget.set(Some(remaining - 1)); + } + Ok(()) + })?; + let process = match StableProcessHandle::capture(self.pid) { + Ok(Some(process)) => process, + Ok(None) => return Ok(None), + Err(error) => match Self::capture(self.pid) { + Ok(None) => return Ok(None), + Ok(Some(current)) if current != self => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "process identity changed", + )); + } + Ok(Some(_)) | Err(_) => return Err(error), + }, + }; + let Some(current) = Self::capture(self.pid)? else { + return Ok(None); + }; + if current != self { + return Err(io::Error::new(io::ErrorKind::InvalidData, "process identity changed")); + } + if !process.matches_current()? { + return Ok(None); + } + Ok(Some(process)) + } +} + +#[derive(Clone, Copy, Debug)] +struct ProcessSnapshot { + identity: ProcessIdentity, + parent: libc::pid_t, + stopped: bool, +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExactSignalResult { + Signaled, + Gone, +} + +struct ProcessFence { + identity: ProcessIdentity, + process: StableProcessHandle, + armed: bool, +} + +impl ProcessFence { + fn acquire(identity: ProcessIdentity, deadline: Instant) -> io::Result> { + let Some(initial) = process_snapshot(identity.pid)? else { return Ok(None) }; + if initial.identity != identity { + return Err(io::Error::new(io::ErrorKind::InvalidData, "process identity changed")); + } + let Some(process) = identity.stable_handle()? else { return Ok(None) }; + // POSIX coalesces repeated stop signals and does not expose their + // sender. Restore only a process observed running before this fence, + // so an aborted cleanup cannot strand a process that we stopped. + let armed = !initial.stopped; + if armed && !process.signal(libc::SIGSTOP)? { + return Ok(None); + } + let fence = Self { identity, process, armed }; + loop { + ensure_helper_active()?; + let Some(current) = process_snapshot(identity.pid)? else { return Ok(None) }; + if current.identity != identity { + return Err(io::Error::new(io::ErrorKind::InvalidData, "process identity changed")); + } + if current.stopped { + return Ok(Some(fence)); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "process did not stop before the legacy shutdown deadline", + )); + } + std::thread::sleep( + deadline.saturating_duration_since(Instant::now()).min(PROCESS_TREE_RETRY_INTERVAL), + ); + } + } + + fn acquire_in_session( + identity: ProcessIdentity, + session: libc::pid_t, + deadline: Instant, + ) -> io::Result> { + let Some(process) = Self::acquire(identity, deadline)? else { return Ok(None) }; + // SAFETY: the exact process is now confirmed stopped, so this + // reusable-PID query cannot race process exit or replacement. + let current_session = unsafe { libc::getsid(identity.pid) }; + if current_session < 0 { + let error = io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ESRCH) { Ok(None) } else { Err(error) }; + } + if current_session != session + || ProcessIdentity::capture(identity.pid)? != Some(identity) + || !process.process.matches_current()? + { + return Ok(None); + } + Ok(Some(process)) + } + + fn still_in_session(&self, session: libc::pid_t) -> io::Result { + if ProcessIdentity::capture(self.identity.pid)? != Some(self.identity) { + return Ok(false); + } + // SAFETY: a confirmed stopped process cannot exit or be replaced + // without an external signal; exact identity probes bracket getsid. + let current_session = unsafe { libc::getsid(self.identity.pid) }; + if current_session < 0 { + let error = io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ESRCH) { Ok(false) } else { Err(error) }; + } + Ok(current_session == session + && self.process.matches_current()? + && ProcessIdentity::capture(self.identity.pid)? == Some(self.identity)) + } + + fn kill(&mut self) -> io::Result<()> { + let result = self.process.signal(libc::SIGKILL); + if result.is_ok() { + self.armed = false; + } + result.map(|_| ()) + } +} + +impl Drop for ProcessFence { + fn drop(&mut self) { + if self.armed { + let _ = self.process.signal(libc::SIGCONT); + } + } +} + +#[cfg(test)] +pub(super) fn terminate_process_tree(process: ProcessIdentity) -> io::Result<()> { + terminate_process_tree_until(process, Instant::now() + PROCESS_TREE_RETRY_TIMEOUT) +} + +#[cfg(test)] +pub(super) fn terminate_process_tree_until( + process: ProcessIdentity, + deadline: Instant, +) -> io::Result<()> { + terminate_process_tree_until_with(process, deadline, |attempt_deadline| { + let tree = FrozenProcessTree::freeze(process, attempt_deadline)?; + tree.terminate_until(attempt_deadline) + }) +} + +#[cfg(test)] +// This injected-attempt helper and all of its callers are test-only. +fn terminate_process_tree_until_with( + process: ProcessIdentity, + deadline: Instant, + mut attempt: impl FnMut(Instant) -> io::Result<()>, +) -> io::Result<()> { + loop { + ensure_helper_active()?; + let error = match attempt(deadline) { + Ok(()) => return Ok(()), + Err(error) => error, + }; + match ProcessIdentity::capture(process.pid) { + Ok(None) => return Ok(()), + Ok(Some(current)) if current != process => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "server process identity changed during termination", + )); + } + Ok(Some(_)) | Err(_) => {} + } + if Instant::now() >= deadline { + return Err(error); + } + } +} + +pub(super) fn capture_process_tree_until( + process: ProcessIdentity, + deadline: Instant, +) -> io::Result { + let tree = freeze_process_tree_until(process, deadline)?; + let sessions = tree.sessions.clone(); + drop(tree); + Ok(CapturedProcessTree { sessions }) +} + +pub(super) fn freeze_process_tree_until( + process: ProcessIdentity, + deadline: Instant, +) -> io::Result { + loop { + ensure_helper_active()?; + let error = match FrozenProcessTree::freeze(process, deadline) { + Ok(tree) => return Ok(tree), + Err(error) => error, + }; + match ProcessIdentity::capture(process.pid) { + Ok(None) => { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "server exited before its process tree was fenced", + )); + } + Ok(Some(current)) if current != process => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "server process identity changed during termination", + )); + } + Ok(Some(_)) | Err(_) => {} + } + if Instant::now() >= deadline { + return Err(error); + } + std::thread::sleep(PROCESS_TREE_RETRY_INTERVAL); + } +} + +pub(super) struct CapturedProcessTree { + sessions: Vec, +} + +impl CapturedProcessTree { + fn session_process(&self, pid: libc::pid_t) -> Option<(&CapturedSession, ProcessIdentity)> { + self.sessions.iter().find_map(|session| { + session + .members + .iter() + .copied() + .find(|process| process.pid == pid) + .map(|process| (session, process)) + }) + } +} + +pub(super) struct CapturedProcessSession { + session: CapturedSession, + _fences: Vec, +} + +impl CapturedProcessSession { + pub(super) fn id(&self) -> libc::pid_t { + self.session.id + } + + pub(super) fn terminate_until(self, deadline: Instant) -> io::Result<()> { + if self.session.kill_until_empty(deadline)? { + Ok(()) + } else { + Err(io::Error::other( + "captured PTY session did not exit before the legacy shutdown deadline", + )) + } + } +} + +pub(super) fn capture_process_session( + pid: libc::pid_t, + server: ProcessIdentity, + captured: &CapturedProcessTree, + deadline: Instant, +) -> io::Result> { + if pid <= 1 + || pid == server.pid + || pid == libc::pid_t::try_from(std::process::id()).unwrap_or(0) + { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid PTY owner process")); + } + let Some((captured_session, process)) = captured.session_process(pid) else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "PTY owner was not captured in the verified server process tree", + )); + }; + if ProcessIdentity::capture(pid)?.is_some_and(|current| current != process) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "PTY owner process identity changed after the verified tree capture", + )); + } + let session = captured_session.id; + // SAFETY: the server identity remains validated by the caller. + let server_session = unsafe { libc::getsid(server.pid) }; + if server_session < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: getsid(0) only queries the detached helper. + let helper_session = unsafe { libc::getsid(0) }; + if helper_session < 0 { + return Err(io::Error::last_os_error()); + } + if session == server_session || session == helper_session { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "PTY owner shares a protected process session", + )); + } + + // Retain every exact process fence across the legacy close mutation. + // Once every live member is stopped, the session cannot fork and at + // least one fence pins its numeric ID until owned termination begins. + let mut fences = Vec::new(); + for process in &captured_session.members { + ensure_helper_active()?; + if let Some(fence) = ProcessFence::acquire_in_session(*process, session, deadline)? { + fences.push(fence); + } + } + loop { + ensure_helper_active()?; + let mut pinned = false; + for fence in &fences { + pinned |= fence.still_in_session(session)?; + } + let member_pids = + cmux_tui_core::process_session::session_member_pids_until(session, deadline)?; + if !pinned { + if member_pids.is_empty() { + return Ok(Some(CapturedProcessSession { + session: CapturedSession { id: session, members: Vec::new() }, + _fences: fences, + })); + } + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "PTY session lost every captured process before ownership could be fenced", + )); + } + + let mut members = Vec::new(); + for member in member_pids { + if let Some(identity) = ProcessIdentity::capture(member)? { + members.push(identity); + } + } + for fence in &fences { + if fence.still_in_session(session)? { + members.push(fence.identity); + } + } + members.sort_by_key(|member| member.pid); + members.dedup(); + + let mut stable = !members.is_empty(); + for member in &members { + let held = fences + .iter() + .find(|fence| fence.identity == *member) + .map(|fence| fence.still_in_session(session)) + .transpose()? + .unwrap_or(false); + if held { + continue; + } + stable = false; + if let Some(fence) = ProcessFence::acquire_in_session(*member, session, deadline)? { + fences.push(fence); + } + } + if stable { + return Ok(Some(CapturedProcessSession { + session: CapturedSession { id: session, members }, + _fences: fences, + })); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "PTY session ownership did not stabilize before the legacy shutdown deadline", + )); + } + std::thread::sleep( + deadline.saturating_duration_since(Instant::now()).min(PROCESS_TREE_RETRY_INTERVAL), + ); + } +} + +#[derive(Clone)] +struct CapturedSession { + id: libc::pid_t, + members: Vec, +} + +impl CapturedSession { + fn kill_until_empty(&self, deadline: Instant) -> io::Result { + loop { + ensure_helper_active()?; + let error = match SessionProbe::acquire(self, deadline) { + Ok(Some(mut probe)) => { + match cmux_tui_core::process_session::kill_until_only_reserved( + self.id, + probe.process.identity.pid, + deadline, + ) { + Ok(true) => match probe.kill() { + Ok(()) => return Ok(true), + Err(error) => error, + }, + Ok(false) => return Ok(false), + Err(error) => error, + } + } + Ok(None) => { + match cmux_tui_core::process_session::session_is_empty_until(self.id, deadline) + { + Ok(true) => return Ok(true), + Ok(false) => io::Error::other( + "captured PTY session lost every exact process before termination", + ), + Err(error) => error, + } + } + Err(error) => error, + }; + if Instant::now() >= deadline { + return Err(error); + } + std::thread::sleep( + deadline.saturating_duration_since(Instant::now()).min(PROCESS_TREE_RETRY_INTERVAL), + ); + } + } +} + +struct SessionProbe { + process: ProcessFence, +} + +impl SessionProbe { + fn acquire(session: &CapturedSession, deadline: Instant) -> io::Result> { + for process in &session.members { + ensure_helper_active()?; + let Some(process) = ProcessFence::acquire_in_session(*process, session.id, deadline)? + else { + continue; + }; + return Ok(Some(Self { process })); + } + Ok(None) + } + + fn kill(&mut self) -> io::Result<()> { + self.process.kill() + } +} + +pub(super) struct FrozenProcessTree { + root: ProcessFence, + descendants: Vec, + sessions: Vec, +} + +impl FrozenProcessTree { + fn freeze(root: ProcessIdentity, deadline: Instant) -> io::Result { + ensure_helper_active()?; + let Some(root) = ProcessFence::acquire(root, deadline)? else { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "server exited before its process tree was fenced", + )); + }; + let mut tree = Self { root, descendants: Vec::new(), sessions: Vec::new() }; + + let helper_pid = libc::pid_t::try_from(std::process::id()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid helper process id"))?; + let mut known = HashSet::from([tree.root.identity.pid, helper_pid]); + for _ in 0..PROCESS_TREE_MAX_ROUNDS { + ensure_helper_active()?; + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "legacy process tree did not stabilize before the shutdown deadline", + )); + } + let parents = std::iter::once(tree.root.identity) + .chain(tree.descendants.iter().map(|process| process.identity)) + .collect::>(); + let mut added = false; + for parent in parents { + ensure_helper_active()?; + for pid in direct_child_pids(parent.pid)? { + ensure_helper_active()?; + if known.contains(&pid) { + continue; + } + let Some(snapshot) = process_snapshot(pid)? else { + continue; + }; + if snapshot.parent != parent.pid { + continue; + } + let Some(process) = ProcessFence::acquire(snapshot.identity, deadline)? else { + continue; + }; + known.insert(pid); + tree.descendants.push(process); + added = true; + } + } + if !added { + tree.sessions = tree.current_sessions()?; + return Ok(tree); + } + } + Err(io::Error::other("legacy process tree did not stabilize")) + } + + fn current_sessions(&self) -> io::Result> { + // SAFETY: getsid only queries live, exact processes held stopped by + // this FrozenProcessTree. + let root_session = unsafe { libc::getsid(self.root.identity.pid) }; + if root_session < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: getsid(0) only queries the detached helper. + let helper_session = unsafe { libc::getsid(0) }; + if helper_session < 0 { + return Err(io::Error::last_os_error()); + } + let mut sessions = Vec::::new(); + for process in &self.descendants { + // SAFETY: every descendant remains stopped and exact until this + // FrozenProcessTree is dropped. + let session = unsafe { libc::getsid(process.identity.pid) }; + if session < 0 { + return Err(io::Error::last_os_error()); + } + if session > 1 && session != root_session && session != helper_session { + if let Some(captured) = sessions.iter_mut().find(|captured| captured.id == session) + { + captured.members.push(process.identity); + } else { + sessions.push(CapturedSession { id: session, members: vec![process.identity] }); + } + } + } + sessions.sort_by_key(|session| session.id); + for session in &mut sessions { + session.members.sort_by_key(|process| process.pid); + session.members.dedup(); + } + Ok(sessions) + } + + pub(super) fn terminate_until(self, deadline: Instant) -> io::Result<()> { + for session in &self.sessions { + if !session.kill_until_empty(deadline)? { + return Err(io::Error::other( + "frozen PTY session did not exit before the legacy shutdown deadline", + )); + } + } + self.kill() + } + + fn kill(mut self) -> io::Result<()> { + for process in self.descendants.iter_mut().rev() { + process.kill()?; + } + self.root.kill() + } +} + +#[cfg(target_os = "macos")] +fn process_snapshot(pid: libc::pid_t) -> io::Result> { + use std::mem::{size_of, zeroed}; + + let mut info = unsafe { zeroed::() }; + let expected = i32::try_from(size_of::()) + .map_err(|_| io::Error::other("process metadata size overflow"))?; + // SAFETY: `info` is a writable buffer of exactly `expected` bytes. + let result = unsafe { + libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, (&raw mut info).cast(), expected) + }; + if result != expected { + match macos_process_status(pid)? { + None | Some(libc::SZOMB) => return Ok(None), + Some(_) => {} + } + return Err(io::Error::other("could not read process birth identity")); + } + let info_pid = + libc::pid_t::try_from(info.pbi_pid).map_err(|_| io::Error::other("invalid process id"))?; + if info_pid != pid { + return Err(io::Error::other("process metadata id mismatch")); + } + let parent = libc::pid_t::try_from(info.pbi_ppid) + .map_err(|_| io::Error::other("invalid parent process id"))?; + if info.pbi_status == libc::SZOMB { + return Ok(None); + } + let started_at = (u128::from(info.pbi_start_tvsec) << 64) | u128::from(info.pbi_start_tvusec); + Ok(Some(ProcessSnapshot { + identity: ProcessIdentity { pid, started_at }, + parent, + stopped: info.pbi_status == libc::SSTOP, + })) +} + +#[cfg(target_os = "macos")] +fn macos_process_status(pid: libc::pid_t) -> io::Result> { + use std::ffi::c_void; + use std::mem::{ManuallyDrop, offset_of, size_of}; + + #[allow(dead_code)] + #[repr(C)] + union ProcessStart { + links: [*mut c_void; 2], + started_at: ManuallyDrop, + } + + #[allow(dead_code)] + #[repr(C)] + struct ExternProcessPrefix { + start: ProcessStart, + vmspace: *mut c_void, + signal_actions: *mut c_void, + flags: libc::c_int, + status: libc::c_char, + pid: libc::pid_t, + } + + let mut query = [libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_PID, pid]; + let query_len = libc::c_uint::try_from(query.len()) + .map_err(|_| io::Error::other("process status query overflow"))?; + let mut record_len = 0; + // SAFETY: the query is a valid KERN_PROC_PID MIB and a null output + // buffer asks the kernel for the record size. + if unsafe { + libc::sysctl( + query.as_mut_ptr(), + query_len, + std::ptr::null_mut(), + &raw mut record_len, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + let error = io::Error::last_os_error(); + if matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::ENOENT)) { + return Ok(None); + } + return Err(error); + } + if record_len == 0 { + return Ok(None); + } + let mut record = vec![0_u8; record_len]; + // SAFETY: `record` owns a writable buffer of `record_len` bytes. + if unsafe { + libc::sysctl( + query.as_mut_ptr(), + query_len, + record.as_mut_ptr().cast(), + &raw mut record_len, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + let error = io::Error::last_os_error(); + if matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::ENOENT)) { + return Ok(None); + } + return Err(error); + } + if record_len < size_of::() { + if record_len == 0 { + return Ok(None); + } + return Err(io::Error::other("process status record was truncated")); + } + // KERN_PROC_PID returns `kinfo_proc`, whose first field is + // `extern_proc`. Read only the stable prefix that contains status and + // PID, without depending on the private remainder of either structure. + let record_pid = unsafe { + record + .as_ptr() + .add(offset_of!(ExternProcessPrefix, pid)) + .cast::() + .read_unaligned() + }; + if record_pid != pid { + return Err(io::Error::other("process status id mismatch")); + } + let status = unsafe { + record.as_ptr().add(offset_of!(ExternProcessPrefix, status)).cast::().read() + }; + u32::try_from(status).map(Some).map_err(|_| io::Error::other("invalid process status")) +} + +#[cfg(target_os = "macos")] +fn direct_child_pids(parent: libc::pid_t) -> io::Result> { + use std::ffi::c_void; + use std::mem::size_of; + + // SAFETY: a null buffer with length zero asks libproc for the count. + let count = unsafe { libc::proc_listchildpids(parent, std::ptr::null_mut(), 0) }; + if count < 0 { + return Err(io::Error::last_os_error()); + } + if count == 0 { + return Ok(Vec::new()); + } + let mut capacity = usize::try_from(count) + .map_err(|_| io::Error::other("invalid child process count"))? + .saturating_add(16); + for _ in 0..4 { + let mut pids = vec![0; capacity]; + let bytes = capacity + .checked_mul(size_of::()) + .and_then(|bytes| i32::try_from(bytes).ok()) + .ok_or_else(|| io::Error::other("child process buffer overflow"))?; + // SAFETY: `pids` owns a writable buffer of `bytes` bytes. + let listed = + unsafe { libc::proc_listchildpids(parent, pids.as_mut_ptr().cast::(), bytes) }; + if listed < 0 { + return Err(io::Error::last_os_error()); + } + let listed = + usize::try_from(listed).map_err(|_| io::Error::other("invalid child process count"))?; + if listed < capacity { + pids.truncate(listed); + pids.retain(|pid| *pid > 1); + return Ok(pids); + } + capacity = capacity + .checked_mul(2) + .ok_or_else(|| io::Error::other("child process buffer overflow"))?; + } + Err(io::Error::other("child process list did not stabilize")) +} + +#[cfg(target_os = "linux")] +fn process_snapshot(pid: libc::pid_t) -> io::Result> { + let path = format!("/proc/{pid}/stat"); + let stat = match std::fs::read(path) { + Ok(stat) => stat, + Err(error) + if error.kind() == io::ErrorKind::NotFound + || error.raw_os_error() == Some(libc::ESRCH) => + { + return Ok(None); + } + Err(error) => return Err(error), + }; + let fields = linux_process_stat_fields(pid, &stat)?; + let state = fields.first().copied().ok_or_else(|| io::Error::other("missing process state"))?; + if state == b"Z" { + return Ok(None); + } + let parent = fields + .get(1) + .and_then(|value| std::str::from_utf8(value).ok()) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| io::Error::other("invalid parent process id"))?; + let started_at = fields + .get(19) + .and_then(|value| std::str::from_utf8(value).ok()) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| io::Error::other("invalid process birth identity"))?; + Ok(Some(ProcessSnapshot { + identity: ProcessIdentity { pid, started_at }, + parent, + stopped: matches!(state, b"T" | b"t"), + })) +} + +#[cfg(target_os = "linux")] +fn linux_process_stat_fields(pid: libc::pid_t, stat: &[u8]) -> io::Result> { + let name_start = stat + .windows(2) + .position(|window| window == b" (") + .ok_or_else(|| io::Error::other("invalid process stat record"))?; + let name_end = stat + .windows(2) + .rposition(|window| window == b") ") + .filter(|name_end| *name_end > name_start) + .ok_or_else(|| io::Error::other("invalid process stat record"))?; + let pid_text = std::str::from_utf8(&stat[..name_start]) + .map_err(|_| io::Error::other("invalid process metadata id"))?; + if pid_text.parse::().ok() != Some(pid) { + return Err(io::Error::other("process metadata id mismatch")); + } + Ok(stat[name_end + 2..] + .split(|byte| byte.is_ascii_whitespace()) + .filter(|field| !field.is_empty()) + .collect()) +} + +#[cfg(target_os = "linux")] +fn direct_child_pids(parent: libc::pid_t) -> io::Result> { + let task_root = format!("/proc/{parent}/task"); + let tasks = match std::fs::read_dir(task_root) { + Ok(tasks) => tasks, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + let mut pids = HashSet::new(); + for task in tasks { + let task = match task { + Ok(task) => task, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + let children = match std::fs::read_to_string(task.path().join("children")) { + Ok(children) => children, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + for pid in children.split_whitespace() { + let pid = pid + .parse::() + .map_err(|_| io::Error::other("invalid child process id"))?; + if pid > 1 { + pids.insert(pid); + } + } + } + let mut pids = pids.into_iter().collect::>(); + pids.sort_unstable(); + Ok(pids) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn process_snapshot(_pid: libc::pid_t) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "stable process identity is unavailable")) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn direct_child_pids(_parent: libc::pid_t) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "process tree enumeration is unavailable")) +} + +#[cfg(test)] +mod tests { + #[cfg(any(target_os = "macos", target_os = "linux"))] + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + #[cfg(any(target_os = "macos", target_os = "linux"))] + use std::sync::mpsc; + + use super::*; + + #[test] + fn stable_identity_refuses_to_signal_the_same_pid_with_a_different_birth() { + let mut child = + Command::new("yes").stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + let process = + ProcessIdentity::capture(libc::pid_t::try_from(child.id()).unwrap()).unwrap().unwrap(); + let stale = + ProcessIdentity { pid: process.pid, started_at: process.started_at.wrapping_add(1) }; + + assert!(stale.signal(libc::SIGKILL).is_err()); + assert!(child.try_wait().unwrap().is_none()); + + child.kill().unwrap(); + child.wait().unwrap(); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn frozen_tree_drop_resumes_with_its_original_process_proof() { + use std::io::Read as _; + use std::os::fd::AsRawFd as _; + + let mut child = + Command::new("yes").stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let descriptor = stdout.as_raw_fd(); + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!(unsafe { libc::fcntl(descriptor, libc::F_SETFL, flags | libc::O_NONBLOCK) }, 0); + let pid = libc::pid_t::try_from(child.id()).unwrap(); + let process = ProcessIdentity::capture(pid).unwrap().unwrap(); + STABLE_HANDLE_CAPTURE_BUDGET.set(Some(1)); + + let tree = + FrozenProcessTree::freeze(process, Instant::now() + Duration::from_secs(1)).unwrap(); + let mut output = [0_u8; 4_096]; + loop { + match stdout.read(&mut output) { + Ok(0) => break, + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => panic!("failed to drain stopped child output: {error}"), + } + } + assert_eq!(unsafe { libc::fcntl(descriptor, libc::F_SETFL, flags) }, 0); + let (resumed_sender, resumed_receiver) = mpsc::sync_channel(1); + let resumed_reader = std::thread::spawn(move || { + resumed_sender.send(stdout.read(&mut output).map(|read| read != 0)).unwrap(); + }); + drop(tree); + let resumed = resumed_receiver.recv_timeout(Duration::from_millis(250)); + + STABLE_HANDLE_CAPTURE_BUDGET.set(None); + // SAFETY: only the exact test-owned child can still use this unreaped PID. + unsafe { + libc::kill(pid, libc::SIGCONT); + } + let _ = child.kill(); + let _ = child.wait(); + resumed_reader.join().unwrap(); + + let resumed = resumed + .expect("resumed child did not publish output before the final failure deadline") + .expect("failed to read resumed child output"); + assert!(resumed, "dropping a process fence left its exact process stopped"); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn process_tree_termination_retries_post_freeze_metadata_errors() { + let mut child = Command::new("sleep") + .arg("60") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let process = + ProcessIdentity::capture(libc::pid_t::try_from(child.id()).unwrap()).unwrap().unwrap(); + let mut attempts = 0; + + let result = terminate_process_tree_until_with( + process, + Instant::now() + Duration::from_secs(1), + |_| { + attempts += 1; + if attempts == 1 { + Err(io::Error::other("transient process metadata read")) + } else { + Ok(()) + } + }, + ); + child.kill().unwrap(); + child.wait().unwrap(); + + assert!(result.is_ok()); + assert_eq!(attempts, 2); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn captured_session_waits_for_an_unavailable_exact_member_to_exit() { + let mut command = Command::new("cat"); + command.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().unwrap(); + let pid = libc::pid_t::try_from(child.id()).unwrap(); + let process = ProcessIdentity::capture(pid).unwrap().unwrap(); + let unavailable = + ProcessIdentity { pid: process.pid, started_at: process.started_at.wrapping_add(1) }; + let session = CapturedSession { id: pid, members: vec![unavailable] }; + let (release_tx, release_rx) = mpsc::sync_channel(1); + let reaper = std::thread::spawn(move || { + release_rx.recv().unwrap(); + child.kill().unwrap(); + child.wait().unwrap() + }); + + let (result_tx, result_rx) = mpsc::sync_channel(1); + let waiter = std::thread::spawn(move || { + result_tx + .send(session.kill_until_empty(Instant::now() + Duration::from_secs(1))) + .unwrap(); + }); + assert!( + result_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "captured session completed while its exact member remained alive" + ); + release_tx.send(()).unwrap(); + let result = result_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + reaper.join().unwrap(); + waiter.join().unwrap(); + + assert!(result.unwrap()); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_process_snapshot_accepts_a_non_utf8_process_name() { + const CHILD_ENV: &str = "CMUX_TUI_TEST_LEGACY_NON_UTF8_PROCESS_NAME"; + const TEST_NAME: &str = "server_lifecycle::legacy_process::tests::linux_process_snapshot_accepts_a_non_utf8_process_name"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME]) + .env(CHILD_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "non-UTF-8 legacy proc subprocess failed: {status}"); + return; + } + + let pid = libc::pid_t::try_from(std::process::id()).unwrap(); + std::fs::write(format!("/proc/self/task/{pid}/comm"), b"cmux-\xff").unwrap(); + + let snapshot = process_snapshot(pid).expect("a valid non-UTF-8 proc name was rejected"); + assert!(snapshot.is_some()); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_child_enumeration_includes_children_spawned_by_worker_threads() { + let (child_tx, child_rx) = mpsc::sync_channel(1); + let worker = std::thread::spawn(move || { + let child = + Command::new("yes").stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + child_tx.send(child.id()).unwrap(); + child + }); + let child_pid = child_rx.recv().unwrap(); + let parent = libc::pid_t::try_from(std::process::id()).unwrap(); + let child_pid = libc::pid_t::try_from(child_pid).unwrap(); + + let children = direct_child_pids(parent).unwrap(); + + let mut child = worker.join().unwrap(); + child.kill().unwrap(); + child.wait().unwrap(); + assert!( + children.contains(&child_pid), + "worker child {child_pid} missing from {children:?}" + ); + } +} diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index db27942a6b0d..1b99eb87cab7 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -465,6 +465,13 @@ impl Session { } } + pub fn shutdown_requested(&self) -> bool { + match self { + Session::Local(mux) => mux.shutdown_requested(), + Session::Remote(_) => false, + } + } + pub fn journal_frontend_event( &self, event: cmux_tui_core::FrontendJournalEvent, @@ -478,12 +485,14 @@ impl Session { } } + #[cfg(test)] pub fn daemon_shutdown_requested(&self) -> bool { match self { Session::Local(mux) => mux.daemon_shutdown_requested(), Session::Remote(_) => false, } } + pub fn invalidate_remote_tree(&self) { if let Session::Remote(remote) = self { remote.invalidate_tree(); diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 5ff0f02752b9..c6274a166cd6 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -1741,11 +1741,25 @@ impl RemoteSession { let stream = transport::connect(path).map_err(|e| { anyhow::anyhow!("cannot connect to session socket {}: {e}", path.display()) })?; - if subscribe { - Self::connect_stream(stream) - } else { - Self::connect_stream_with_subscription(stream, false) - } + Self::connect_local_stream_with_subscription(stream, path, subscribe) + } + + pub(crate) fn connect_local_stream( + stream: Box, + path: &Path, + ) -> anyhow::Result> { + Self::connect_local_stream_with_subscription(stream, path, true) + } + + fn connect_local_stream_with_subscription( + stream: Box, + path: &Path, + subscribe: bool, + ) -> anyhow::Result> { + let transport = RemoteTransport::json_lines(stream).map_err(|error| { + anyhow::anyhow!("cannot configure JSON-lines session transport: {error}") + })?; + Self::connect_transport_with_provider_authority(transport, None, Some(path), subscribe) } /// Connect over an already-established full-duplex byte stream. @@ -1754,6 +1768,7 @@ impl RemoteSession { /// establishment outside `RemoteSession` lets clients use a local socket, /// an SSH relay, or another authenticated tunnel without teaching the /// session and rendering layers about those transports. + #[cfg_attr(not(test), allow(dead_code))] pub fn connect_stream(stream: Box) -> anyhow::Result> { Self::connect_stream_with_subscription(stream, true) } @@ -1776,19 +1791,20 @@ impl RemoteSession { transport: RemoteTransport, subscribe: bool, ) -> anyhow::Result> { - Self::connect_transport_with_provider_authority(transport, None, subscribe) + Self::connect_transport_with_provider_authority(transport, None, None, subscribe) } pub fn connect_provider_transport( transport: RemoteTransport, authority: BearerToken, ) -> anyhow::Result> { - Self::connect_transport_with_provider_authority(transport, Some(authority), true) + Self::connect_transport_with_provider_authority(transport, Some(authority), None, true) } fn connect_transport_with_provider_authority( transport: RemoteTransport, provider_workspace_authority: Option, + local_path: Option<&Path>, subscribe: bool, ) -> anyhow::Result> { let RemoteTransport { mut reader, writer, abort } = transport; @@ -1847,17 +1863,21 @@ impl RemoteSession { } })?; - if let Err(error) = session.initialize(subscribe) { + if let Err(error) = session.initialize(local_path, subscribe) { session.disconnect_transport(); return Err(error); } Ok(session) } - fn initialize(&self, subscribe: bool) -> anyhow::Result<()> { + fn initialize(&self, local_path: Option<&Path>, subscribe: bool) -> anyhow::Result<()> { // Identify the endpoint and register this connection before any optional subscription. let ident = self.request(json!({"cmd": "identify"}))?; - validate_remote_identity(&ident)?; + if let Some(path) = local_path { + crate::server_lifecycle::validate_local_identity(&ident, path)?; + } else { + validate_remote_identity(&ident)?; + } *self.capabilities.lock().unwrap() = identity_capabilities(&ident); let mut client_info = json!({"cmd": "set-client-info", "kind": "tui"}); if let Some(hostname) = local_hostname() { @@ -1883,6 +1903,10 @@ impl RemoteSession { client_info["capabilities"] = json!(negotiated); } self.request(client_info)?; + #[cfg(debug_assertions)] + if let Some(signal) = std::env::var_os("CMUX_TUI_TEST_CLIENT_REGISTERED_SIGNAL") { + fs::write(signal, b"1")?; + } if subscribe { self.prime_local_subscription(); if let Err(error) = self.request(self.subscription_request()) { @@ -2569,8 +2593,12 @@ impl RemoteSession { return Err(RemoteRequestError::Transport(error).into()); } - if self.shutdown.load(Ordering::Acquire) { - self.pending.lock().unwrap().remove(&id); + // A routed response has already removed this id and owns the receiver. + // Shutdown cancels only a request whose pending entry we can still take; + // otherwise consume the routed response or shutdown sentinel below. + if self.shutdown.load(Ordering::Acquire) + && self.pending.lock().unwrap().remove(&id).is_some() + { return Err(RemoteRequestError::Shutdown.into()); } diff --git a/cmux-tui/crates/cmux-tui/tests/cli.rs b/cmux-tui/crates/cmux-tui/tests/cli.rs index 60a0d496feca..a0cf93d4d3d1 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli.rs @@ -2,17 +2,23 @@ use std::collections::VecDeque; use std::fs; #[cfg(unix)] +use std::fs::File; +#[cfg(unix)] use std::io::{BufRead, BufReader, Read, Write}; #[cfg(unix)] use std::net::Shutdown; #[cfg(unix)] use std::os::fd::AsRawFd; #[cfg(unix)] +use std::os::fd::FromRawFd; +#[cfg(unix)] use std::os::unix::ffi::OsStringExt; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt, symlink}; #[cfg(unix)] use std::os::unix::net::{UnixListener, UnixStream}; +#[cfg(unix)] +use std::os::unix::process::{CommandExt, ExitStatusExt}; use std::path::PathBuf; use std::process::{Child, Command, Output, Stdio}; use std::sync::mpsc; @@ -20,45 +26,143 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use cmux_tui_core::platform::transport; +#[cfg(unix)] +struct TestFifoSignal(File); + +#[cfg(unix)] +impl TestFifoSignal { + fn create(path: &std::path::Path) -> Self { + let path_name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: path_name is a valid, NUL-terminated path in the test directory. + assert_eq!(unsafe { libc::mkfifo(path_name.as_ptr(), 0o600) }, 0); + Self(fs::OpenOptions::new().read(true).write(true).open(path).unwrap()) + } + + fn send(&mut self) { + self.0.write_all(b"1").unwrap(); + } + + fn receive(&mut self, timeout: Duration) { + let timeout = timeout.as_millis().clamp(1, i32::MAX as u128) as i32; + let mut descriptor = + libc::pollfd { fd: self.0.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: descriptor points to one initialized pollfd for this call. + assert_eq!(unsafe { libc::poll(&raw mut descriptor, 1, timeout) }, 1); + assert_ne!(descriptor.revents & libc::POLLIN, 0); + let mut byte = [0_u8; 1]; + self.0.read_exact(&mut byte).unwrap(); + } +} + +#[cfg(unix)] +fn wait_for_child_exit( + child: &mut Child, + timeout: Duration, + failure: &str, +) -> std::process::ExitStatus { + let pid = child.id(); + let (exited_tx, exited_rx) = mpsc::sync_channel(1); + let observer = std::thread::spawn(move || { + let mut status = std::mem::MaybeUninit::::zeroed(); + // SAFETY: status points to writable siginfo storage. WNOWAIT observes + // this exact child transition without consuming its exit status. + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + status.as_mut_ptr(), + libc::WEXITED | libc::WNOWAIT, + ) + }; + let result = if result == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) }; + exited_tx.send(result).unwrap(); + }); + match exited_rx.recv_timeout(timeout) { + Ok(Ok(())) => {} + Ok(Err(error)) => panic!("waitid failed: {error}"), + Err(_) => { + let _ = child.kill(); + let _ = exited_rx.recv_timeout(Duration::from_secs(1)); + let _ = child.wait(); + observer.join().unwrap(); + panic!("{failure}"); + } + } + observer.join().unwrap(); + child.wait().unwrap() +} + struct HeadlessServer { child: Child, socket: PathBuf, state: PathBuf, dir: PathBuf, + shutdown_owner_diagnostic: Option, } impl HeadlessServer { fn start(name: &str) -> Self { - Self::start_with_config(name, None) + Self::start_with(name, false, &[]) + } + + fn start_with(name: &str, ephemeral: bool, environment: &[(&str, &str)]) -> Self { + Self::start_with_options(name, ephemeral, environment, None, false) + } + + fn start_with_shutdown_owner_diagnostic(name: &str, ephemeral: bool) -> Self { + Self::start_with_options(name, ephemeral, &[], None, true) } fn start_with_config(name: &str, config_contents: Option<&str>) -> Self { + Self::start_with_options(name, false, &[], config_contents, false) + } + + fn start_with_options( + name: &str, + ephemeral: bool, + environment: &[(&str, &str)], + config_contents: Option<&str>, + capture_shutdown_owner_diagnostic: bool, + ) -> Self { let dir = unique_temp_dir(name); fs::create_dir_all(&dir).unwrap(); let socket = dir.join("mux.sock"); let state = dir.join("state"); - // A headless fixture must never inherit the developer's real plugin - // configuration. Server-owned plugins are configured explicitly by - // the tests that exercise them. let config = dir.join("config.json"); if let Some(contents) = config_contents { fs::write(&config, contents).unwrap(); } - let child = Command::new(bin()) - .args(["--headless", "--socket"]) - .arg(&socket) - .arg("--state") - .arg(&state) - .env("CMUX_TUI_CONFIG", &config) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let server = Self { child, socket, state, dir }; + let mut command = Command::new(bin()); + command.args(["--headless", "--socket"]).arg(&socket); + if ephemeral { + command.arg("--ephemeral"); + } else { + command.arg("--state").arg(&state); + } + command.env("CMUX_TUI_CONFIG", &config); + for (name, value) in environment { + command.env(name, value); + } + let shutdown_owner_diagnostic = + capture_shutdown_owner_diagnostic.then(|| dir.join("shutdown-owner-diagnostic.txt")); + if let Some(path) = &shutdown_owner_diagnostic { + command.env("CMUX_TUI_TEST_SHUTDOWN_OWNER_DIAGNOSTIC", path); + } + let child = command.stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap(); + let server = Self { child, socket, state, dir, shutdown_owner_diagnostic }; server.wait_for_socket(); server } + fn shutdown_owner_diagnostic(&self) -> String { + let Some(path) = &self.shutdown_owner_diagnostic else { + return "shutdown owner diagnostic was not requested".to_string(); + }; + fs::read_to_string(path).unwrap_or_else(|error| { + format!("shutdown owner diagnostic was not published at {}: {error}", path.display()) + }) + } + fn wait_for_socket(&self) { let deadline = Instant::now() + Duration::from_secs(15); while Instant::now() < deadline { @@ -200,18 +304,6 @@ impl HeadlessServer { } } -#[cfg(unix)] -fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if child.try_wait().unwrap().is_some() { - return true; - } - std::thread::sleep(Duration::from_millis(10)); - } - false -} - #[cfg(unix)] fn wait_for_processes_to_exit(pids: &[u32], timeout: Duration) -> bool { let deadline = Instant::now() + timeout; @@ -266,6 +358,57 @@ fn try_json_socket_request( (response["ok"] == true).then(|| response["data"].clone()) } +#[cfg(unix)] +#[test] +fn server_establishes_shutdown_ownership_before_publishing_its_listener() { + let dir = unique_temp_dir("watchdog-start-failure"); + fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("mux.sock"); + let marker = dir.join("watchdog-starting"); + let release = dir.join("watchdog-release"); + for path in [&marker, &release] { + let path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: path is a valid, NUL-terminated path in the test directory. + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + } + let (ready_sender, ready_receiver) = mpsc::sync_channel(1); + let ready_marker = marker.clone(); + let ready_reader = std::thread::spawn(move || { + use std::io::Read as _; + + let mut marker = File::open(ready_marker).unwrap(); + let mut signal = [0_u8; 1]; + marker.read_exact(&mut signal).unwrap(); + ready_sender.send(()).unwrap(); + }); + let mut child = Command::new(bin()) + .args(["--headless", "--ephemeral", "--socket"]) + .arg(&socket) + .env("CMUX_TUI_TEST_WATCHDOG_START_FAILURE", &marker) + .env("CMUX_TUI_TEST_WATCHDOG_START_FAILURE_RELEASE", &release) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + ready_receiver + .recv_timeout(Duration::from_secs(15)) + .expect("watchdog failure hook was not reached"); + ready_reader.join().unwrap(); + let listener_was_published = socket.exists(); + + fs::write(&release, b"release").unwrap(); + let status = child.wait().unwrap(); + let _ = fs::remove_file(&socket); + let _ = fs::remove_dir_all(&dir); + + assert!(!status.success(), "forced watchdog failure unexpectedly started the server"); + assert!( + !listener_was_published, + "server accepted clients before its shutdown watchdog owned cleanup" + ); +} + fn terminal_host_pids(root: &std::path::Path) -> Vec { fs::read_dir(root) .ok() @@ -289,6 +432,40 @@ fn process_exists(pid: u32) -> bool { std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } +#[cfg(target_os = "linux")] +fn process_is_active(pid: u32) -> bool { + let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else { + return false; + }; + stat.rsplit_once(") ") + .and_then(|(_, fields)| fields.split_whitespace().next()) + .map_or_else(|| process_exists(pid), |state| state != "Z") +} + +#[cfg(all(unix, not(target_os = "linux")))] +fn process_is_active(pid: u32) -> bool { + let output = Command::new("ps") + .args(["-o", "stat=", "-p", &pid.to_string()]) + .output() + .expect("inspect process state"); + output.status.success() + && output.stdout.iter().copied().find(|byte| !byte.is_ascii_whitespace()) != Some(b'Z') +} + +#[cfg(unix)] +fn process_group_is_active(group: u32) -> bool { + let output = Command::new("ps") + .args(["-axo", "pgid=,stat="]) + .output() + .expect("inspect process-group state"); + let group = group.to_string(); + String::from_utf8_lossy(&output.stdout).lines().any(|line| { + let mut fields = line.split_whitespace(); + fields.next() == Some(group.as_str()) + && fields.next().is_some_and(|state| !state.starts_with('Z')) + }) +} + #[cfg(unix)] fn process_group_exists(pid: u32) -> bool { let Ok(pid) = libc::pid_t::try_from(pid) else { return false }; @@ -310,277 +487,879 @@ fn process_group_exists(_pid: u32) -> bool { false } -fn wait_for_socket_path(path: &std::path::Path) { - let deadline = Instant::now() + Duration::from_secs(15); - while Instant::now() < deadline { - if transport::connect(path).is_ok() { - return; - } - std::thread::sleep(Duration::from_millis(25)); +#[cfg(unix)] +fn wait_for_pid_file(path: &std::path::Path, timeout: Duration) -> u32 { + fn read_pid(path: &std::path::Path) -> Option { + fs::read_to_string(path).ok()?.trim().parse().ok() } - panic!("server did not accept connections at {}", path.display()); -} - -fn lifecycle_cli(args: &[&str]) -> Output { - Command::new(bin()) - .args(args) - .env("LC_ALL", "C") - .env("LC_MESSAGES", "C") - .env("LANG", "C") - .env_remove("CMUX_TUI_SOCKET") - .env_remove("CMUX_MUX_SOCKET") - .output() - .unwrap() -} -#[cfg(unix)] -fn accept_with_timeout(listener: &UnixListener, timeout: Duration) -> std::io::Result { let deadline = Instant::now() + timeout; - loop { - let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "server did not receive the lifecycle connection", - )); - }; - let timeout_ms = i32::try_from(remaining.as_millis().max(1)).unwrap_or(i32::MAX); - let mut descriptor = - libc::pollfd { fd: listener.as_raw_fd(), events: libc::POLLIN, revents: 0 }; - // SAFETY: descriptor contains one valid listener fd and remains alive - // for the complete poll call. - let ready = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; - if ready > 0 { - return listener.accept().map(|(stream, _)| stream); - } - if ready == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "server did not receive the lifecycle connection", - )); + + #[cfg(target_os = "linux")] + { + let parent = path.parent().unwrap(); + // SAFETY: inotify_init1 returns a new owned descriptor on success. + let descriptor = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) }; + assert!(descriptor >= 0, "could not create PID publication observer"); + // SAFETY: descriptor is newly owned by this test. + let observer = unsafe { File::from_raw_fd(descriptor) }; + let parent = std::ffi::CString::new(parent.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: parent is a valid, NUL-terminated directory path. + assert!( + unsafe { + libc::inotify_add_watch( + observer.as_raw_fd(), + parent.as_ptr(), + libc::IN_CREATE | libc::IN_CLOSE_WRITE | libc::IN_MOVED_TO, + ) + } >= 0 + ); + loop { + if let Some(pid) = read_pid(path) { + return pid; + } + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("PID file was not published before its deadline"); + let timeout = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; + let mut poll_descriptor = + libc::pollfd { fd: observer.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: poll_descriptor points to one initialized pollfd for this call. + assert_eq!(unsafe { libc::poll(&raw mut poll_descriptor, 1, timeout) }, 1); + let mut events = [0_u8; 4_096]; + // SAFETY: events is writable for its full length. + assert!( + unsafe { + libc::read(observer.as_raw_fd(), events.as_mut_ptr().cast(), events.len()) + } > 0 + ); } - let error = std::io::Error::last_os_error(); - if error.kind() != std::io::ErrorKind::Interrupted { - return Err(error); + } + + #[cfg(target_os = "macos")] + { + let directory = File::open(path.parent().unwrap()).unwrap(); + // SAFETY: kqueue returns a new owned descriptor on success. + let descriptor = unsafe { libc::kqueue() }; + assert!(descriptor >= 0, "could not create PID publication observer"); + // SAFETY: descriptor is newly owned by this test. + let observer = unsafe { File::from_raw_fd(descriptor) }; + let change = libc::kevent { + ident: directory.as_raw_fd() as libc::uintptr_t, + filter: libc::EVFILT_VNODE, + flags: libc::EV_ADD | libc::EV_ENABLE | libc::EV_CLEAR, + fflags: libc::NOTE_WRITE | libc::NOTE_EXTEND | libc::NOTE_RENAME, + data: 0, + udata: std::ptr::null_mut(), + }; + // SAFETY: change describes the live directory descriptor for this call. + assert_eq!( + unsafe { + libc::kevent( + observer.as_raw_fd(), + &raw const change, + 1, + std::ptr::null_mut(), + 0, + std::ptr::null(), + ) + }, + 0 + ); + loop { + if let Some(pid) = read_pid(path) { + return pid; + } + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("PID file was not published before its deadline"); + let timeout = libc::timespec { + tv_sec: remaining.as_secs() as _, + tv_nsec: remaining.subsec_nanos() as _, + }; + let mut event = std::mem::MaybeUninit::::uninit(); + // SAFETY: event points to one writable kevent and timeout lives for this call. + assert_eq!( + unsafe { + libc::kevent( + observer.as_raw_fd(), + std::ptr::null(), + 0, + event.as_mut_ptr(), + 1, + &raw const timeout, + ) + }, + 1 + ); } } + + #[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] + panic!("PID publication observer is unavailable on this test platform") } #[cfg(unix)] -struct ServerEventSubscription { - writer: Box, - receiver: mpsc::Receiver>, - reader_thread: Option>, - pending: VecDeque, +struct LegacyServerProcess { + child: Child, + socket: PathBuf, + dir: PathBuf, + descendant_pid_file: PathBuf, + descendant_pid_published: Option, } #[cfg(unix)] -impl ServerEventSubscription { - fn start(path: &std::path::Path) -> Self { - let stream = transport::connect(path).unwrap(); - let mut writer = stream.try_clone_box().unwrap(); - let (sender, receiver) = mpsc::channel(); - let reader_thread = std::thread::spawn(move || { - for line in BufReader::new(stream).lines() { - let value = line.map_err(|error| error.to_string()).and_then(|line| { - serde_json::from_str(&line).map_err(|error| error.to_string()) - }); - if sender.send(value).is_err() { - break; - } - } - }); - writeln!(writer, r#"{{"id":1,"cmd":"subscribe"}}"#).unwrap(); - writer.flush().unwrap(); +impl LegacyServerProcess { + fn start(name: &str, scenario: &str, reported_pid: Option) -> Self { + Self::start_with_surface_pid(name, scenario, reported_pid, None) + } - let deadline = Instant::now() + Duration::from_secs(5); - let mut pending = VecDeque::new(); - loop { - let remaining = deadline - .checked_duration_since(Instant::now()) - .expect("server did not acknowledge the readiness subscription"); - let message: serde_json::Value = receiver - .recv_timeout(remaining) - .expect("server did not acknowledge the readiness subscription") - .expect("readiness subscription returned invalid JSON"); - if message["id"].as_u64() == Some(1) { - assert_eq!(message["ok"], true, "readiness subscription failed: {message}"); - break; - } - pending.push_back(message); + fn start_with_surface_pid( + name: &str, + scenario: &str, + reported_pid: Option, + surface_pid: Option, + ) -> Self { + let dir = unique_temp_dir(name); + fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("mux.sock"); + let descendant_pid_file = dir.join("descendant.pid"); + let descendant_pid_published_path = dir.join("descendant-pid-published"); + let descendant_pid_published = (scenario == "reparent-on-close") + .then(|| TestFifoSignal::create(&descendant_pid_published_path)); + let ready_path = dir.join("ready"); + let mut ready = TestFifoSignal::create(&ready_path); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--ignored", "--exact", "legacy_server_process_helper"]) + .env("CMUX_TUI_TEST_LEGACY_SOCKET", &socket) + .env("CMUX_TUI_TEST_LEGACY_SCENARIO", scenario) + .env("CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_FILE", &descendant_pid_file) + .env("CMUX_TUI_TEST_LEGACY_READY_SIGNAL", &ready_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + if descendant_pid_published.is_some() { + command.env( + "CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_PUBLISHED_SIGNAL", + &descendant_pid_published_path, + ); + } + if let Some(reported_pid) = reported_pid { + command.env("CMUX_TUI_TEST_LEGACY_REPORTED_PID", reported_pid.to_string()); } + if let Some(surface_pid) = surface_pid { + command.env("CMUX_TUI_TEST_LEGACY_SURFACE_PID", surface_pid.to_string()); + } + let child = command.spawn().unwrap(); + ready.receive(Duration::from_secs(15)); + Self { child, socket, dir, descendant_pid_file, descendant_pid_published } + } - Self { writer, receiver, reader_thread: Some(reader_thread), pending } + fn descendant_pid(&self) -> Option { + fs::read_to_string(&self.descendant_pid_file).ok()?.trim().parse().ok() } - fn next_before(&mut self, deadline: Instant) -> serde_json::Value { - if let Some(message) = self.pending.pop_front() { - return message; - } - let remaining = deadline - .checked_duration_since(Instant::now()) - .expect("interactive owner did not register its TUI client"); - self.receiver - .recv_timeout(remaining) - .expect("interactive owner did not register its TUI client") - .expect("readiness subscription returned invalid JSON") + fn receive_descendant_pid(&mut self, timeout: Duration) -> u32 { + self.descendant_pid_published + .as_mut() + .expect("legacy descendant PID receipt was not configured") + .receive(timeout); + self.descendant_pid().expect("legacy descendant PID receipt preceded its complete file") } } #[cfg(unix)] -impl Drop for ServerEventSubscription { +impl Drop for LegacyServerProcess { fn drop(&mut self) { - let _ = self.writer.shutdown(Shutdown::Both); - if let Some(reader_thread) = self.reader_thread.take() { - let _ = reader_thread.join(); + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(pid) = self.descendant_pid().and_then(|pid| libc::pid_t::try_from(pid).ok()) { + // SAFETY: test cleanup targets the PID written by this fixture. + let _ = unsafe { libc::kill(pid, libc::SIGKILL) }; } + let _ = fs::remove_file(&self.socket); + let _ = fs::remove_dir_all(&self.dir); } } #[cfg(unix)] -fn has_tui_client(socket: &std::path::Path) -> bool { - let clients = - lifecycle_cli(&["--json", "--socket", socket.to_str().unwrap(), "client", "list"]); - clients.status.success() - && json_output(&clients).as_array().is_some_and(|clients| { - clients.iter().any(|client| client["client_kind"].as_str() == Some("tui")) - }) -} - -#[cfg(unix)] -fn wait_for_owner_server_ready(socket: &std::path::Path, owner: &mut PtyChild) { - let mut events = ServerEventSubscription::start(socket); - let mut tui_attached = has_tui_client(socket); - let deadline = Instant::now() + Duration::from_secs(10); - loop { - if let Some(status) = owner.child.as_mut().unwrap().try_wait().unwrap() { - panic!("interactive owner exited before shutdown: {status}"); +#[test] +#[ignore] +fn legacy_server_process_helper() { + let socket = PathBuf::from(std::env::var_os("CMUX_TUI_TEST_LEGACY_SOCKET").unwrap()); + let scenario = std::env::var("CMUX_TUI_TEST_LEGACY_SCENARIO").unwrap(); + let reported_pid = std::env::var("CMUX_TUI_TEST_LEGACY_REPORTED_PID") + .ok() + .map(|value| value.parse::().unwrap()) + .unwrap_or_else(std::process::id); + let reported_surface_pid = std::env::var("CMUX_TUI_TEST_LEGACY_SURFACE_PID") + .ok() + .map(|value| value.parse::().unwrap()); + let mut reparent_on_close = None; + if matches!( + scenario.as_str(), + "success" + | "kill-caller" + | "cleanup-failure" + | "applied-close-error" + | "applied-close-disconnect" + | "exit-after-close" + | "persistent-close-error" + | "concurrent-client" + | "draining-client" + | "browser-surface" + ) { + let mut command = Command::new("yes"); + command.stdout(Stdio::null()).stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); } - assert!( - Instant::now() < deadline, - "interactive owner did not make its server lifecycle-ready" - ); - if tui_attached { - let identity = - json_socket_request(socket, serde_json::json!({"id":1,"cmd":"identify"})); - if identity["lifecycle_ready"].as_bool() == Some(true) { - return; - } - continue; + let descendant = command.spawn().unwrap(); + fs::write( + std::env::var_os("CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_FILE").unwrap(), + descendant.id().to_string(), + ) + .unwrap(); + drop(descendant); + } else if scenario == "zombie-child" { + let descendant = + Command::new("true").stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + let descendant_pid = descendant.id(); + fs::write( + std::env::var_os("CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_FILE").unwrap(), + descendant_pid.to_string(), + ) + .unwrap(); + drop(descendant); + let mut status = std::mem::MaybeUninit::::zeroed(); + // SAFETY: status points to writable siginfo storage. WNOWAIT observes + // the exact child exit without consuming its zombie status. + assert_eq!( + unsafe { + libc::waitid( + libc::P_PID, + descendant_pid as libc::id_t, + status.as_mut_ptr(), + libc::WEXITED | libc::WNOWAIT, + ) + }, + 0 + ); + } else if scenario == "dead-owned-surface" { + let mut command = Command::new("/bin/bash"); + command + .args([ + "--noprofile", + "--norc", + "-c", + concat!( + "trap '' HUP; ", + "(trap '' HUP; while :; do sleep 60; done) & ", + "printf '%s' \"$!\" > \"$CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_FILE\"" + ), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); } - let event = events.next_before(deadline); - if matches!(event["event"].as_str(), Some("client-attached" | "client-changed")) - && event["kind"].as_str() == Some("tui") - { - tui_attached = true; + let mut direct = command.spawn().unwrap(); + assert!(direct.wait().unwrap().success()); + let pid_file = + PathBuf::from(std::env::var_os("CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_FILE").unwrap()); + let _ = wait_for_pid_file(&pid_file, Duration::from_secs(5)); + } else if scenario == "reparent-on-close" { + let mut command = Command::new("/bin/bash"); + command + .args([ + "--noprofile", + "--norc", + "-c", + concat!( + "set -m; trap '' HUP; ", + "(trap '' HUP; while :; do sleep 60; done) & ", + "printf '%s' \"$!\" > \"$CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_FILE\"; ", + "printf '1' > \"$CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_PUBLISHED_SIGNAL\"; wait" + ), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); } - assert_ne!(event["event"], "overflow", "readiness subscription overflowed"); + reparent_on_close = Some(command.spawn().unwrap()); } -} + let listener = transport::listen(&socket).unwrap(); + fs::write(std::env::var_os("CMUX_TUI_TEST_LEGACY_READY_SIGNAL").unwrap(), b"1").unwrap(); + let serve_identify = |stream: &mut Box| { + let mut reader = BufReader::new(stream.try_clone_box().unwrap()); + let mut request = String::new(); + reader.read_line(&mut request).unwrap(); + let identify_request: serde_json::Value = serde_json::from_str(&request).unwrap(); + assert_eq!(identify_request["cmd"].as_str(), Some("identify")); + let release = cmux_tui_core::release::ReleaseIdentity::current( + cmux_tui_core::server::PROTOCOL_VERSION, + ); + let modern = + matches!(scenario.as_str(), "compatible-failure" | "mismatched-modern-failure"); + let (version, build_commit, ghostty_commit, protocol) = if scenario == "compatible-failure" + { + (release.version, release.build_commit, release.ghostty_commit, release.protocol) + } else { + ("0.0.0-stale".to_string(), None, None, 8) + }; + let capabilities = if modern { vec!["server-shutdown-v1"] } else { Vec::new() }; + writeln!( + stream, + "{}", + serde_json::json!({ + "id": identify_request["id"], + "ok": true, + "data": { + "app": "cmux-tui", + "session": "test", + "pid": reported_pid, + "version": version, + "build_commit": build_commit, + "ghostty_commit": ghostty_commit, + "protocol": protocol, + "capabilities": capabilities, + }, + }) + ) + .unwrap(); + }; -#[cfg(unix)] -struct SocketFileGuard(PathBuf); + let reject_shutdown = |stream: &mut Box| { + let mut reader = BufReader::new(stream.try_clone_box().unwrap()); + let mut request = String::new(); + reader.read_line(&mut request).unwrap(); + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + assert_eq!(request["cmd"].as_str(), Some("shutdown")); + writeln!( + stream, + "{}", + serde_json::json!({ + "id": request["id"], + "ok": false, + "error": "shutdown failed", + }) + ) + .unwrap(); + }; -#[cfg(unix)] -impl Drop for SocketFileGuard { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); + let mut initial_stream = listener.accept().unwrap(); + let caller_pid = initial_stream.peer_process_id().unwrap().unwrap(); + serve_identify(&mut initial_stream); + if matches!(scenario.as_str(), "compatible-failure" | "mismatched-modern-failure") { + reject_shutdown(&mut initial_stream); + loop { + std::thread::park(); + } } -} - -#[test] -fn server_lifecycle_help_and_typos_do_not_fall_back_to_startup_help() { - for args in [ - &["server", "--help"][..], - &["server", "stop", "--help"][..], - &["server", "status", "--help"][..], - &["server", "reload-config", "--help"][..], - ] { - let output = lifecycle_cli(args); - assert_success(&output); - let help = String::from_utf8(output.stdout).unwrap(); - assert!(help.contains("cmux server"), "{help}"); - assert!(!help.contains("cmux [OPTIONS] Start a session"), "{help}"); + drop(initial_stream); + if scenario == "spoofed-pid" { + loop { + std::thread::park(); + } } - let leaf_typo = lifecycle_cli(&["server", "stpo"]); - assert_eq!(leaf_typo.status.code(), Some(2)); - let leaf_error = String::from_utf8(leaf_typo.stderr).unwrap(); - assert!(leaf_error.contains("Did you mean `stop`?"), "{leaf_error}"); - assert!(!leaf_error.contains("START OPTIONS"), "{leaf_error}"); - - let json_typo = lifecycle_cli(&["--json", "server", "stpo"]); - assert_eq!(json_typo.status.code(), Some(2)); - let json_error: serde_json::Value = serde_json::from_slice(&json_typo.stderr).unwrap(); - assert_eq!(json_error["code"], "usage.invalid"); - assert!(json_error["message"].as_str().unwrap().contains("Did you mean `stop`?")); - - let output_flag_used_as_a_socket_value = - lifecycle_cli(&["--socket", "--json", "server", "stpo"]); - assert_eq!(output_flag_used_as_a_socket_value.status.code(), Some(2)); - let error = String::from_utf8(output_flag_used_as_a_socket_value.stderr).unwrap(); - assert!(error.contains("Did you mean `stop`?"), "{error}"); - assert!(!error.trim_start().starts_with('{'), "{error}"); - - let misplaced_start_option = lifecycle_cli(&["--term", "xterm-256color", "server", "start"]); - assert_eq!(misplaced_start_option.status.code(), Some(2)); - let error = String::from_utf8(misplaced_start_option.stderr).unwrap(); - assert!(error.contains("after `server start`"), "{error}"); - assert!(!error.contains("Did you mean `start`?"), "{error}"); - - let scope_typo = lifecycle_cli(&["sever", "stop"]); - assert_eq!(scope_typo.status.code(), Some(2)); - let scope_error = String::from_utf8(scope_typo.stderr).unwrap(); - assert!(scope_error.contains("Did you mean `server`?"), "{scope_error}"); - assert!(!scope_error.contains("START OPTIONS"), "{scope_error}"); + let mut stream = listener.accept().unwrap(); + serve_identify(&mut stream); + let mut reader = BufReader::new(stream.try_clone_box().unwrap()); + let mut request = String::new(); + let mut snapshot_index = 0; + let mut list_client_scans = 0; + 'snapshots: loop { + request.clear(); + if reader.read_line(&mut request).unwrap() == 0 { + loop { + std::thread::park(); + } + } + let list_request: serde_json::Value = serde_json::from_str(&request).unwrap(); + if list_request["cmd"].as_str() == Some("list-clients") { + list_client_scans += 1; + let mut clients = vec![serde_json::json!({ + "client": 1, + "transport": "unix", + "self": true, + })]; + if scenario == "concurrent-client" + || (scenario == "draining-client" && list_client_scans == 1) + { + clients.push(serde_json::json!({ + "client": 2, + "transport": "unix", + "self": false, + })); + } + if writeln!( + stream, + "{}", + serde_json::json!({ + "id": list_request["id"], + "ok": true, + "data": clients, + }) + ) + .is_err() + { + // The bounded helper can reject exclusivity and close this + // client before its last response is written. A real legacy + // server keeps serving after that client disconnects. + loop { + std::thread::park(); + } + } + continue; + } + assert_eq!(list_request["cmd"].as_str(), Some("list-workspaces")); + if scenario == "cleanup-failure" { + writeln!( + stream, + "{}", + serde_json::json!({ + "id": list_request["id"], + "ok": false, + "error": "cleanup unavailable", + }) + ) + .unwrap(); + loop { + std::thread::park(); + } + } + assert!(matches!( + scenario.as_str(), + "success" + | "kill-caller" + | "applied-close-error" + | "applied-close-disconnect" + | "exit-after-close" + | "persistent-close-error" + | "concurrent-client" + | "draining-client" + | "zombie-child" + | "reparent-on-close" + | "unowned-surface" + | "browser-surface" + | "dead-surface" + | "dead-owned-surface" + )); + let surfaces: &[u64] = match (scenario.as_str(), snapshot_index) { + ("zombie-child", _) => &[], + ("persistent-close-error", _) | (_, 0) => &[41], + ("success" | "kill-caller", 1) => &[42], + _ => &[], + }; + let tabs = surfaces + .iter() + .map(|surface| { + if scenario == "browser-surface" { + serde_json::json!({"surface": surface, "kind": "browser"}) + } else if matches!(scenario.as_str(), "dead-surface" | "dead-owned-surface") { + serde_json::json!({"surface": surface, "kind": "pty", "dead": true}) + } else { + serde_json::json!({"surface": surface, "kind": "pty"}) + } + }) + .collect::>(); + writeln!( + stream, + "{}", + serde_json::json!({ + "id": list_request["id"], + "ok": true, + "data": { + "workspaces": [{ + "screens": [{ + "panes": [{ + "tabs": tabs, + }], + }], + }], + }, + }) + ) + .unwrap(); + for &expected_surface in surfaces { + request.clear(); + if reader.read_line(&mut request).unwrap() == 0 { + loop { + std::thread::park(); + } + } + let mut close_request: serde_json::Value = serde_json::from_str(&request).unwrap(); + if close_request["cmd"].as_str() == Some("process-info") { + assert_eq!(close_request["surface"].as_u64(), Some(expected_surface)); + let pid = if scenario == "dead-surface" { + None + } else if let Some(pid) = reported_surface_pid { + Some(pid) + } else { + Some( + fs::read_to_string( + std::env::var_os("CMUX_TUI_TEST_LEGACY_DESCENDANT_PID_FILE").unwrap(), + ) + .unwrap() + .trim() + .parse::() + .unwrap(), + ) + }; + writeln!( + stream, + "{}", + serde_json::json!({ + "id": close_request["id"], + "ok": true, + "data": {"pid": pid}, + }) + ) + .unwrap(); + request.clear(); + if reader.read_line(&mut request).unwrap() == 0 { + loop { + std::thread::park(); + } + } + close_request = serde_json::from_str(&request).unwrap(); + } + assert_eq!(close_request["cmd"].as_str(), Some("close-surface")); + assert_eq!(close_request["surface"].as_u64(), Some(expected_surface)); + if scenario == "exit-after-close" { + drop(reader); + drop(stream); + drop(listener); + let _ = fs::remove_file(&socket); + return; + } + if scenario == "applied-close-disconnect" { + drop(reader); + drop(stream); + stream = listener.accept().unwrap(); + serve_identify(&mut stream); + reader = BufReader::new(stream.try_clone_box().unwrap()); + snapshot_index += 1; + continue 'snapshots; + } + let response = + if matches!(scenario.as_str(), "applied-close-error" | "persistent-close-error") { + serde_json::json!({ + "id": close_request["id"], + "ok": false, + "error": "surface cleanup reported an error", + }) + } else { + serde_json::json!({ + "id": close_request["id"], + "ok": true, + "data": {}, + }) + }; + writeln!(stream, "{response}").unwrap(); + if scenario == "reparent-on-close" + && let Some(mut direct) = reparent_on_close.take() + { + direct.kill().unwrap(); + direct.wait().unwrap(); + } + if scenario == "kill-caller" && expected_surface == 41 { + let caller_pid = libc::pid_t::try_from(caller_pid).unwrap(); + assert_eq!(unsafe { libc::kill(caller_pid, libc::SIGKILL) }, 0); + } + } + snapshot_index += 1; + } } -#[test] -fn server_lifecycle_start_rejects_inline_relay_ticket_without_echoing_secret() { - let secret = "inline-server-start-secret-marker"; - for args in [ - &["server", "start", "--relay-ticket", secret][..], - &["--json", "server", "start", "--relay-ticket", secret][..], - &["server", "start", "--relay-ticket=inline-server-start-secret-marker"][..], - &["server", "start", "--relay-ticket", "--help"][..], - &["server", "start", "--relay-ticket=inline-server-start-secret-marker", "--help"][..], - &["server", "start", "--help", "--relay-ticket", secret][..], - &["server", "start", "--session", "--relay-ticket", secret][..], - ] { - let output = lifecycle_cli(args); - assert_eq!(output.status.code(), Some(2)); - let diagnostic = String::from_utf8(output.stderr).unwrap(); - assert!(diagnostic.contains("inline relay tickets are not accepted"), "{diagnostic}"); - assert!(!diagnostic.contains(secret), "{diagnostic}"); +fn wait_for_socket_path(path: &std::path::Path) { + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + if transport::connect(path).is_ok() { + return; + } + std::thread::sleep(Duration::from_millis(25)); } + panic!("server did not accept connections at {}", path.display()); } -#[test] -fn public_command_payload_preserves_relay_ticket_argument() { - let output = lifecycle_cli(&[ - "workspace", - "current", - "run", - "--", - "tool", - "--relay-ticket", - "child-value", - ]); - let diagnostic = String::from_utf8(output.stderr).unwrap(); - - assert_ne!(output.status.code(), Some(2), "{diagnostic}"); - assert!(!diagnostic.contains("inline relay tickets are not accepted"), "{diagnostic}"); +fn lifecycle_cli(args: &[&str]) -> Output { + Command::new(bin()) + .args(args) + .env("LC_ALL", "C") + .env("LC_MESSAGES", "C") + .env("LANG", "C") + .env_remove("CMUX_TUI_SOCKET") + .env_remove("CMUX_MUX_SOCKET") + .output() + .unwrap() } -#[test] -fn public_resource_value_preserves_relay_ticket_literal() { - let output = lifecycle_cli(&["workspace", "current", "rename", "--name", "--relay-ticket"]); - let diagnostic = String::from_utf8(output.stderr).unwrap(); - - assert_ne!(output.status.code(), Some(2), "{diagnostic}"); - assert!(!diagnostic.contains("inline relay tickets are not accepted"), "{diagnostic}"); +#[cfg(unix)] +fn accept_with_timeout(listener: &UnixListener, timeout: Duration) -> std::io::Result { + let deadline = Instant::now() + timeout; + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "server did not receive the lifecycle connection", + )); + }; + let timeout_ms = i32::try_from(remaining.as_millis().max(1)).unwrap_or(i32::MAX); + let mut descriptor = + libc::pollfd { fd: listener.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: descriptor contains one valid listener fd and remains alive + // for the complete poll call. + let ready = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; + if ready > 0 { + return listener.accept().map(|(stream, _)| stream); + } + if ready == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "server did not receive the lifecycle connection", + )); + } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error); + } + } } -#[test] +#[cfg(unix)] +struct ServerEventSubscription { + writer: Box, + receiver: mpsc::Receiver>, + reader_thread: Option>, + pending: VecDeque, +} + +#[cfg(unix)] +impl ServerEventSubscription { + fn start(path: &std::path::Path) -> Self { + let stream = transport::connect(path).unwrap(); + let mut writer = stream.try_clone_box().unwrap(); + let (sender, receiver) = mpsc::channel(); + let reader_thread = std::thread::spawn(move || { + for line in BufReader::new(stream).lines() { + let value = line.map_err(|error| error.to_string()).and_then(|line| { + serde_json::from_str(&line).map_err(|error| error.to_string()) + }); + if sender.send(value).is_err() { + break; + } + } + }); + writeln!(writer, r#"{{"id":1,"cmd":"subscribe"}}"#).unwrap(); + writer.flush().unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut pending = VecDeque::new(); + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("server did not acknowledge the readiness subscription"); + let message: serde_json::Value = receiver + .recv_timeout(remaining) + .expect("server did not acknowledge the readiness subscription") + .expect("readiness subscription returned invalid JSON"); + if message["id"].as_u64() == Some(1) { + assert_eq!(message["ok"], true, "readiness subscription failed: {message}"); + break; + } + pending.push_back(message); + } + + Self { writer, receiver, reader_thread: Some(reader_thread), pending } + } + + fn next_before(&mut self, deadline: Instant) -> serde_json::Value { + if let Some(message) = self.pending.pop_front() { + return message; + } + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("interactive owner did not register its TUI client"); + self.receiver + .recv_timeout(remaining) + .expect("interactive owner did not register its TUI client") + .expect("readiness subscription returned invalid JSON") + } +} + +#[cfg(unix)] +impl Drop for ServerEventSubscription { + fn drop(&mut self) { + let _ = self.writer.shutdown(Shutdown::Both); + if let Some(reader_thread) = self.reader_thread.take() { + let _ = reader_thread.join(); + } + } +} + +#[cfg(unix)] +fn has_tui_client(socket: &std::path::Path) -> bool { + let clients = + lifecycle_cli(&["--json", "--socket", socket.to_str().unwrap(), "client", "list"]); + clients.status.success() + && json_output(&clients).as_array().is_some_and(|clients| { + clients.iter().any(|client| client["client_kind"].as_str() == Some("tui")) + }) +} + +#[cfg(unix)] +fn wait_for_owner_server_ready(socket: &std::path::Path, owner: &mut PtyChild) { + let mut events = ServerEventSubscription::start(socket); + let mut tui_attached = has_tui_client(socket); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = owner.child.as_mut().unwrap().try_wait().unwrap() { + panic!("interactive owner exited before shutdown: {status}"); + } + assert!( + Instant::now() < deadline, + "interactive owner did not make its server lifecycle-ready" + ); + if tui_attached { + let identity = + json_socket_request(socket, serde_json::json!({"id":1,"cmd":"identify"})); + if identity["lifecycle_ready"].as_bool() == Some(true) { + return; + } + continue; + } + let event = events.next_before(deadline); + if matches!(event["event"].as_str(), Some("client-attached" | "client-changed")) + && event["kind"].as_str() == Some("tui") + { + tui_attached = true; + } + assert_ne!(event["event"], "overflow", "readiness subscription overflowed"); + } +} + +#[cfg(unix)] +struct SocketFileGuard(PathBuf); + +#[cfg(unix)] +impl Drop for SocketFileGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +#[test] +fn server_lifecycle_help_and_typos_do_not_fall_back_to_startup_help() { + for args in [ + &["server", "--help"][..], + &["server", "stop", "--help"][..], + &["server", "status", "--help"][..], + &["server", "reload-config", "--help"][..], + ] { + let output = lifecycle_cli(args); + assert_success(&output); + let help = String::from_utf8(output.stdout).unwrap(); + assert!(help.contains("cmux server"), "{help}"); + assert!(!help.contains("cmux [OPTIONS] Start a session"), "{help}"); + } + + let leaf_typo = lifecycle_cli(&["server", "stpo"]); + assert_eq!(leaf_typo.status.code(), Some(2)); + let leaf_error = String::from_utf8(leaf_typo.stderr).unwrap(); + assert!(leaf_error.contains("Did you mean `stop`?"), "{leaf_error}"); + assert!(!leaf_error.contains("START OPTIONS"), "{leaf_error}"); + + let json_typo = lifecycle_cli(&["--json", "server", "stpo"]); + assert_eq!(json_typo.status.code(), Some(2)); + let json_error: serde_json::Value = serde_json::from_slice(&json_typo.stderr).unwrap(); + assert_eq!(json_error["code"], "usage.invalid"); + assert!(json_error["message"].as_str().unwrap().contains("Did you mean `stop`?")); + + let output_flag_used_as_a_socket_value = + lifecycle_cli(&["--socket", "--json", "server", "stpo"]); + assert_eq!(output_flag_used_as_a_socket_value.status.code(), Some(2)); + let error = String::from_utf8(output_flag_used_as_a_socket_value.stderr).unwrap(); + assert!(error.contains("Did you mean `stop`?"), "{error}"); + assert!(!error.trim_start().starts_with('{'), "{error}"); + + let misplaced_start_option = lifecycle_cli(&["--term", "xterm-256color", "server", "start"]); + assert_eq!(misplaced_start_option.status.code(), Some(2)); + let error = String::from_utf8(misplaced_start_option.stderr).unwrap(); + assert!(error.contains("after `server start`"), "{error}"); + assert!(!error.contains("Did you mean `start`?"), "{error}"); + + let scope_typo = lifecycle_cli(&["sever", "stop"]); + assert_eq!(scope_typo.status.code(), Some(2)); + let scope_error = String::from_utf8(scope_typo.stderr).unwrap(); + assert!(scope_error.contains("Did you mean `server`?"), "{scope_error}"); + assert!(!scope_error.contains("START OPTIONS"), "{scope_error}"); +} + +#[test] +fn server_lifecycle_start_rejects_inline_relay_ticket_without_echoing_secret() { + let secret = "inline-server-start-secret-marker"; + for args in [ + &["server", "start", "--relay-ticket", secret][..], + &["--json", "server", "start", "--relay-ticket", secret][..], + &["server", "start", "--relay-ticket=inline-server-start-secret-marker"][..], + &["server", "start", "--relay-ticket", "--help"][..], + &["server", "start", "--relay-ticket=inline-server-start-secret-marker", "--help"][..], + &["server", "start", "--help", "--relay-ticket", secret][..], + &["server", "start", "--session", "--relay-ticket", secret][..], + ] { + let output = lifecycle_cli(args); + assert_eq!(output.status.code(), Some(2)); + let diagnostic = String::from_utf8(output.stderr).unwrap(); + assert!(diagnostic.contains("inline relay tickets are not accepted"), "{diagnostic}"); + assert!(!diagnostic.contains(secret), "{diagnostic}"); + } +} + +#[test] +fn public_command_payload_preserves_relay_ticket_argument() { + let output = lifecycle_cli(&[ + "workspace", + "current", + "run", + "--", + "tool", + "--relay-ticket", + "child-value", + ]); + let diagnostic = String::from_utf8(output.stderr).unwrap(); + + assert_ne!(output.status.code(), Some(2), "{diagnostic}"); + assert!(!diagnostic.contains("inline relay tickets are not accepted"), "{diagnostic}"); +} + +#[test] +fn public_resource_value_preserves_relay_ticket_literal() { + let output = lifecycle_cli(&["workspace", "current", "rename", "--name", "--relay-ticket"]); + let diagnostic = String::from_utf8(output.stderr).unwrap(); + + assert_ne!(output.status.code(), Some(2), "{diagnostic}"); + assert!(!diagnostic.contains("inline relay tickets are not accepted"), "{diagnostic}"); +} + +#[test] fn server_lifecycle_start_rejects_output_modes_without_starting_an_owner() { let dir = unique_temp_dir("server-start-output-mode"); fs::create_dir_all(&dir).unwrap(); @@ -1239,7 +2018,12 @@ fn named_server_status_stop_alias_and_durable_restart_preserve_topology() { socket.to_str().unwrap(), ]); assert_success(&stop_alias); - assert!(wait_for_child_exit(&mut first, Duration::from_secs(10))); + let status = wait_for_child_exit( + &mut first, + Duration::from_secs(10), + "server remained alive after session stop", + ); + assert!(status.success(), "server exited with {status}"); let mut restarted = spawn(); wait_for_socket_path(&socket); @@ -1307,7 +2091,7 @@ fn explicit_socket_keeps_state_in_platform_root() { .stderr(Stdio::piped()) .spawn() .unwrap(); - let server = HeadlessServer { child, socket, state, dir }; + let server = HeadlessServer { child, socket, state, dir, shutdown_owner_diagnostic: None }; server.wait_for_socket(); let registry_exists = || { @@ -1813,993 +2597,2293 @@ fn session_reset_state_refuses_live_terminal_host_state() { fs::remove_dir_all(dir).unwrap(); } -#[cfg(any(target_os = "ios", target_os = "macos", target_os = "linux", target_os = "android"))] -#[test] -fn session_reset_state_refuses_orphan_terminal_host_live_marker() { - let dir = unique_temp_dir("session-reset-orphan-host-marker"); - fs::create_dir_all(&dir).unwrap(); - let state = dir.join("state"); - let session = "schema-reset-orphan-host"; - - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let database = find_session_database(&state, session); - let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); - fs::create_dir_all(&host_root).unwrap(); - fs::set_permissions(&host_root, fs::Permissions::from_mode(0o700)).unwrap(); - let live_marker = host_root.join("orphan.live"); - let live_file = fs::OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .mode(0o600) - .open(&live_marker) - .unwrap(); - assert_eq!(unsafe { libc::flock(live_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, 0); - let preview = Command::new(bin()) - .args(["--json", "session", session, "reset-state", "--state"]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() - .unwrap(); - assert_success(&preview); - let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); - let confirm_reset = preview["confirm_reset"].as_str().unwrap(); +#[cfg(any(target_os = "ios", target_os = "macos", target_os = "linux", target_os = "android"))] +#[test] +fn session_reset_state_refuses_orphan_terminal_host_live_marker() { + let dir = unique_temp_dir("session-reset-orphan-host-marker"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + let session = "schema-reset-orphan-host"; + + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let database = find_session_database(&state, session); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); + fs::create_dir_all(&host_root).unwrap(); + fs::set_permissions(&host_root, fs::Permissions::from_mode(0o700)).unwrap(); + let live_marker = host_root.join("orphan.live"); + let live_file = fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&live_marker) + .unwrap(); + assert_eq!(unsafe { libc::flock(live_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, 0); + let preview = Command::new(bin()) + .args(["--json", "session", session, "reset-state", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&preview); + let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); + let confirm_reset = preview["confirm_reset"].as_str().unwrap(); + + let reset = Command::new(bin()) + .args([ + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + confirm_reset, + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!reset.status.success(), "reset unexpectedly succeeded"); + assert!(database.exists(), "reset removed the registry while a live marker was present"); + assert!(live_marker.exists(), "reset removed the orphan live marker"); + + fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(any(target_os = "ios", target_os = "macos", target_os = "linux", target_os = "android"))] +#[test] +fn session_reset_state_removes_dead_orphan_terminal_host_live_marker() { + let dir = unique_temp_dir("session-reset-dead-orphan-host-marker"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + let session = "schema-reset-dead-orphan-host"; + + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let database = find_session_database(&state, session); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); + fs::create_dir_all(&host_root).unwrap(); + fs::set_permissions(&host_root, fs::Permissions::from_mode(0o700)).unwrap(); + let live_marker = host_root.join("orphan.live"); + fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&live_marker) + .unwrap(); + let preview = Command::new(bin()) + .args(["--json", "session", session, "reset-state", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&preview); + let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); + let confirm_reset = preview["confirm_reset"].as_str().unwrap(); + + let reset = Command::new(bin()) + .args([ + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + confirm_reset, + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&reset); + assert!(!database.exists(), "reset left the registry after removing a dead live marker"); + assert!(!host_root.exists(), "reset left terminal-host state after removing a dead marker"); + + fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn session_reset_state_rejects_stale_preview_when_targets_change() { + let dir = unique_temp_dir("session-reset-stale-preview"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + let session = "schema-reset-stale-preview"; + + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let database = find_session_database(&state, session); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); + assert!(!host_root.exists()); + + let preview = Command::new(bin()) + .args(["--json", "session", session, "reset-state", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&preview); + let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); + let stale_confirm_reset = preview["confirm_reset"].as_str().unwrap(); + + fs::create_dir_all(&host_root).unwrap(); + fs::set_permissions(&host_root, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(host_root.join("sentinel"), b"new-host-state").unwrap(); + + let reset = Command::new(bin()) + .args([ + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + stale_confirm_reset, + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!reset.status.success(), "reset accepted a stale preview token"); + assert!(database.exists(), "reset removed the registry with a stale token"); + assert!(host_root.join("sentinel").exists(), "reset removed newly appeared host state"); + + fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn session_reset_state_rejects_preview_for_recreated_registry() { + let dir = unique_temp_dir("session-reset-recreated-registry"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + let session = "schema-reset-recreated-registry"; + + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let original_database = find_session_database(&state, session); + let session_dir = original_database.parent().unwrap().to_path_buf(); + + let preview = Command::new(bin()) + .args(["--json", "session", session, "reset-state", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&preview); + let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); + let stale_confirm_reset = preview["confirm_reset"].as_str().unwrap(); + + fs::remove_dir_all(&session_dir).unwrap(); + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let recreated_database = find_session_database(&state, session); + + let reset = Command::new(bin()) + .args([ + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + stale_confirm_reset, + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!reset.status.success(), "reset accepted a token from a replaced registry"); + assert!(recreated_database.exists(), "reset removed the recreated registry"); + + fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn session_reset_state_rejects_preview_when_session_sidecar_appears() { + let dir = unique_temp_dir("session-reset-new-sidecar"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + let session = "schema-reset-new-sidecar"; + + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let database = find_session_database(&state, session); + let session_dir = database.parent().unwrap(); + + let preview = Command::new(bin()) + .args(["--json", "session", session, "reset-state", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&preview); + let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); + let stale_confirm_reset = preview["confirm_reset"].as_str().unwrap(); + + let sidecar = session_dir.join("workspace-registry.sqlite3-wal"); + fs::write(&sidecar, b"new-sidecar-state").unwrap(); + + let reset = Command::new(bin()) + .args([ + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + stale_confirm_reset, + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!reset.status.success(), "reset accepted a token before a new sidecar existed"); + assert!(database.exists(), "reset removed the registry with a stale token"); + assert!(sidecar.exists(), "reset removed a sidecar that was not previewed"); + + fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn session_reset_state_rejects_preview_when_nested_session_file_appears() { + let dir = unique_temp_dir("session-reset-new-nested-file"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + let session = "schema-reset-new-nested-file"; + + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let database = find_session_database(&state, session); + let session_dir = database.parent().unwrap(); + let nested_dir = session_dir.join("nested"); + fs::create_dir_all(&nested_dir).unwrap(); + fs::write(nested_dir.join("previewed"), b"old").unwrap(); + + let preview = Command::new(bin()) + .args(["--json", "session", session, "reset-state", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&preview); + let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); + let stale_confirm_reset = preview["confirm_reset"].as_str().unwrap(); + + let unpreviewed = nested_dir.join("unpreviewed"); + fs::write(&unpreviewed, b"new").unwrap(); + + let reset = Command::new(bin()) + .args([ + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + stale_confirm_reset, + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!reset.status.success(), "reset accepted a token before a nested file existed"); + assert!(database.exists(), "reset removed the registry with a stale token"); + assert!(unpreviewed.exists(), "reset removed a nested file that was not previewed"); + + fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn session_reset_state_bad_token_does_not_mutate_state_root() { + let dir = unique_temp_dir("session-reset-bad-token-no-mutation"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + fs::create_dir_all(&state).unwrap(); + fs::set_permissions(&state, fs::Permissions::from_mode(0o755)).unwrap(); + let session = "schema-reset-bad-token"; + let resetter = cmux_tui_core::PersistentSessionStateResetter::new(state.clone()); + let session_dir = resetter.session_dir(session); + fs::create_dir_all(&session_dir).unwrap(); + + let reset = Command::new(bin()) + .args([ + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + "wrong-token", + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!reset.status.success(), "reset accepted a wrong confirmation token"); + assert!(!state.join("session-locks").exists(), "wrong-token reset created session locks"); + assert_eq!(fs::metadata(&state).unwrap().permissions().mode() & 0o777, 0o755); + + fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn session_reset_state_rejects_symlinked_terminal_host_state() { + let dir = unique_temp_dir("session-reset-symlink-host"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + let session = "schema-reset-symlink-host"; + + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let database = find_session_database(&state, session); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); + let outside_host_root = dir.join("outside-host-state"); + fs::create_dir_all(&outside_host_root).unwrap(); + fs::set_permissions(&outside_host_root, fs::Permissions::from_mode(0o700)).unwrap(); + let outside_sentinel = outside_host_root.join("sentinel"); + fs::write(&outside_sentinel, b"outside").unwrap(); + symlink(&outside_host_root, &host_root).unwrap(); + + let preview = Command::new(bin()) + .args(["--json", "session", session, "reset-state", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&preview); + let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); + let confirm_reset = preview["confirm_reset"].as_str().unwrap(); + + let reset = Command::new(bin()) + .args([ + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + confirm_reset, + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!reset.status.success(), "reset accepted a symlinked host root"); + assert!(database.exists(), "reset removed the registry after rejecting host root"); + assert!(fs::symlink_metadata(&host_root).unwrap().file_type().is_symlink()); + assert!(outside_sentinel.exists(), "reset mutated the symlink target"); + + fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn session_reset_state_rejects_symlinked_state_root() { + let dir = unique_temp_dir("session-reset-symlink-root"); + fs::create_dir_all(&dir).unwrap(); + let actual_state = dir.join("actual-state"); + let state = dir.join("state-link"); + let session = "schema-reset-symlink-root"; + fs::create_dir_all(&actual_state).unwrap(); + fs::write(actual_state.join("root-sentinel"), b"keep").unwrap(); + symlink(&actual_state, &state).unwrap(); + + drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); + let database = find_session_database(&actual_state, session); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); + fs::create_dir_all(&host_root).unwrap(); + fs::write(host_root.join("orphaned-sidecar"), b"stale").unwrap(); + + let preview = Command::new(bin()) + .args(["--json", "session", session, "reset-state", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!preview.status.success(), "preview accepted a symlinked state root"); + let preview_error: serde_json::Value = serde_json::from_slice(&preview.stderr).unwrap(); + assert_eq!(preview_error["code"], "session.reset_state.filesystem"); + + let reset = Command::new(bin()) + .args([ + "--json", + "session", + session, + "reset-state", + "--force", + "--confirm-reset", + "unused", + "--state", + ]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert!(!reset.status.success(), "reset accepted a symlinked state root"); + let reset_error: serde_json::Value = serde_json::from_slice(&reset.stderr).unwrap(); + assert_eq!(reset_error["code"], "session.reset_state.filesystem"); + assert!(database.exists(), "reset removed stale database through symlinked state root"); + assert!(host_root.exists(), "reset removed terminal-host state through symlinked state root"); + assert!(fs::symlink_metadata(&state).unwrap().file_type().is_symlink()); + assert!(actual_state.join("root-sentinel").exists(), "reset removed root sibling data"); + + fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn session_reset_state_missing_target_does_not_mutate_state_root() { + let dir = unique_temp_dir("session-reset-missing-target"); + fs::create_dir_all(&dir).unwrap(); + let state = dir.join("not-cmux-state"); + fs::create_dir_all(&state).unwrap(); + #[cfg(unix)] + fs::set_permissions(&state, fs::Permissions::from_mode(0o755)).unwrap(); + fs::write(state.join("sentinel"), b"keep").unwrap(); + + let reset = Command::new(bin()) + .args(["--json", "session", "missing-session", "reset-state", "--force", "--state"]) + .arg(&state) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&reset); + let reset: serde_json::Value = serde_json::from_slice(&reset.stdout).unwrap(); + assert_eq!(reset["removed_session_state"], false); + assert_eq!(reset["removed_terminal_hosts"], false); + assert!(state.join("sentinel").exists()); + assert!(!state.join("session-locks").exists()); + #[cfg(unix)] + assert_eq!(fs::metadata(&state).unwrap().permissions().mode() & 0o777, 0o755); + + fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn durable_registry_survives_sigkill_and_rejects_a_second_writer() { + let dir = unique_temp_dir("durable-restart"); + fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("mux.sock"); + let second_socket = dir.join("second.sock"); + let state = dir.join("state"); + let spawn = |socket: &std::path::Path| { + Command::new(bin()) + .args(["--headless", "--session", "durable", "--socket"]) + .arg(socket) + .arg("--state") + .arg(&state) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap() + }; + + let mut first = spawn(&socket); + wait_for_socket_path(&socket); + let identify = json_socket_request(&socket, serde_json::json!({"id":1,"cmd":"identify"})); + let registry_id = identify["registry_id"].as_str().unwrap().to_string(); + let generation = identify["generation"].as_str().unwrap().to_string(); + let created = json_socket_request( + &socket, + serde_json::json!({ + "id":2, + "cmd":"create-workspace", + "name":"survivor", + "key":"018f6e21-7b70-7e70-8000-000000000044", + "origin":"process-test", + "mutation_id":"create-durable", + "expected_revision":0, + }), + ); + assert_eq!(created["workspace_revision"], 1); + + let mut second = spawn(&second_socket); + let second_status = second.wait().unwrap(); + assert!(!second_status.success()); + let mut second_stderr = String::new(); + second.stderr.take().unwrap().read_to_string(&mut second_stderr).unwrap(); + assert!(second_stderr.contains("already owned by another daemon"), "{second_stderr}"); + + // Child::kill is SIGKILL on Unix, intentionally bypassing graceful + // cleanup and leaving the old socket behind. + first.kill().unwrap(); + first.wait().unwrap(); + let _ = fs::remove_file(&socket); + + let mut restarted = spawn(&socket); + wait_for_socket_path(&socket); + let recovered = + json_socket_request(&socket, serde_json::json!({"id":3,"cmd":"list-workspaces"})); + assert_eq!(recovered["registry_id"], registry_id); + assert_ne!(recovered["generation"], generation); + assert_eq!(recovered["workspace_revision"], 1); + assert_eq!(recovered["workspaces"][0]["key"], "018f6e21-7b70-7e70-8000-000000000044"); + assert_eq!(recovered["workspaces"][0]["name"], "survivor"); + assert!(recovered["workspaces"][0]["screens"].as_array().unwrap().is_empty()); + + restarted.kill().unwrap(); + restarted.wait().unwrap(); + let _ = fs::remove_dir_all(dir); +} + +#[cfg(unix)] +#[test] +fn machine_agent_is_a_real_entrypoint_without_changing_ordinary_cli_dispatch() { + let machine_agent = Command::new(bin()) + .env("LC_ALL", "C") + .env("LC_MESSAGES", "C") + .env("LANG", "C") + .args(["machine-agent", "--help"]) + .output() + .unwrap(); + assert_success(&machine_agent); + let help = String::from_utf8(machine_agent.stdout).unwrap(); + assert!(help.starts_with("cmux machine-agent - share one local cmux session")); + assert!(help.contains("Authenticate with the configured host before retrying.")); + assert!(!help.contains("cmux machine register")); + assert!(!help.contains("BatchMode")); + + let version = Command::new(bin()).arg("--version").output().unwrap(); + assert_success(&version); + assert!(String::from_utf8(version.stdout).unwrap().starts_with("cmux ")); +} + +#[test] +fn ghostty_config_helper_outputs_resolved_file_defaults() { + let dir = unique_temp_dir("ghostty-config-helper-output"); + let config_home = dir.join("config"); + let ghostty_dir = config_home.join("ghostty"); + fs::create_dir_all(&ghostty_dir).unwrap(); + fs::write( + ghostty_dir.join("config"), + "foreground = #010203\n\ + background = #040506\n\ + cursor-color = #070809\n\ + cursor-style = block_hollow\n\ + cursor-style-blink = false\n\ + palette = 2=#0a0b0c\n", + ) + .unwrap(); + + let output = Command::new(bin()) + .arg("__ghostty-config-defaults") + .env("HOME", dir.join("home")) + .env("CFFIXED_USER_HOME", dir.join("home")) + .env("XDG_CONFIG_HOME", &config_home) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + assert_success(&output); + let stdout = String::from_utf8(output.stdout).unwrap(); + fs::remove_dir_all(dir).unwrap(); + + assert!(stdout.contains("foreground = #010203\n"), "{stdout}"); + assert!(stdout.contains("background = #040506\n"), "{stdout}"); + assert!(stdout.contains("cursor-color = #070809\n"), "{stdout}"); + assert!(stdout.contains("cursor-style = block_hollow\n"), "{stdout}"); + assert!(stdout.contains("cursor-style-blink = false\n"), "{stdout}"); + assert!(stdout.contains("palette = 2=#0a0b0c\n"), "{stdout}"); +} + +#[cfg(target_os = "linux")] +#[test] +fn ghostty_config_helper_scrubs_provider_env_before_desktop_probe() { + let dir = unique_temp_dir("ghostty-config-helper-provider-env"); + let config_home = dir.join("config"); + let ghostty_dir = config_home.join("ghostty"); + let theme_dir = ghostty_dir.join("themes"); + let bin_dir = dir.join("bin"); + fs::create_dir_all(&theme_dir).unwrap(); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write( + ghostty_dir.join("config"), + "window-theme = system\n\ + theme = dark:Dark Direct Probe, light:Light Direct Probe\n", + ) + .unwrap(); + fs::write(theme_dir.join("Dark Direct Probe"), "foreground = #010203\n").unwrap(); + fs::write(theme_dir.join("Light Direct Probe"), "foreground = #a0b0c0\n").unwrap(); + let gdbus = bin_dir.join("gdbus"); + fs::write( + &gdbus, + "#!/bin/sh\n\ + if [ \"${CMUX_MACHINE_PROVIDER_TOKEN+x}\" = x ] || [ \"${CMUX_PROVIDER_WORKSPACE_AUTHORITY+x}\" = x ]; then\n\ + \tprintf '(,)\\n'\n\ + \texit 0\n\ + fi\n\ + printf '(,)\\n'\n", + ) + .unwrap(); + fs::set_permissions(&gdbus, fs::Permissions::from_mode(0o700)).unwrap(); + + let output = Command::new(bin()) + .arg("__ghostty-config-defaults") + .env("HOME", dir.join("home")) + .env("CFFIXED_USER_HOME", dir.join("home")) + .env("XDG_CONFIG_HOME", &config_home) + .env("PATH", &bin_dir) + .env("CMUX_MACHINE_PROVIDER_TOKEN", "direct-helper-token") + .env("CMUX_PROVIDER_WORKSPACE_AUTHORITY", "direct-helper-authority") + .env_remove("AppleInterfaceStyle") + .env_remove("GTK_THEME") + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + assert_success(&output); + let stdout = String::from_utf8(output.stdout).unwrap(); + fs::remove_dir_all(dir).unwrap(); + + assert!(stdout.contains("foreground = #010203\n"), "{stdout}"); + assert!(!stdout.contains("foreground = #a0b0c0\n"), "{stdout}"); +} + +#[cfg(unix)] +#[test] +fn machine_agent_argument_failures_are_stable_and_localized() { + let output = Command::new(bin()) + .env("LC_ALL", "ja_JP.UTF-8") + .env("LC_MESSAGES", "ja_JP.UTF-8") + .env("LANG", "ja_JP.UTF-8") + .args(["machine-agent", "--cloud-port", "invalid"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("--cloud-port の値が無効です: invalid")); + assert!(!stderr.contains("machine-agent を開始または続行できませんでした")); +} + +#[cfg(unix)] +#[test] +fn known_daemon_human_output_uses_selected_locale() { + let dir = unique_temp_dir("known-daemon-locale"); + let client = dir.join("client"); + fs::create_dir_all(&client).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&client, fs::Permissions::from_mode(0o700)).unwrap(); + let state = serde_json::json!({ + "version": 1, + "daemons": { + "enrolled-fingerprint": { + "fingerprint": "enrolled-fingerprint", + "name": "enrolled-host", + "public_key": "unused", + "route_hints": ["ssh://enrolled.example"], + "auth": "enrolled", + "first_seen_at_unix": 1, + "last_used_at_unix": 2 + }, + "carrier-fingerprint": { + "fingerprint": "carrier-fingerprint", + "name": "carrier-host", + "public_key": "unused", + "route_hints": ["ssh://carrier.example"], + "auth": "carrier", + "first_seen_at_unix": 1, + "last_used_at_unix": 2 + } + } + }); + let known = client.join("known-daemons.json"); + fs::write(&known, serde_json::to_vec(&state).unwrap()).unwrap(); + fs::set_permissions(&known, fs::Permissions::from_mode(0o600)).unwrap(); + + let localized = |arguments: &[&str]| { + Command::new(bin()) + .args(arguments) + .arg("--state-dir") + .arg(&dir) + .env("LC_ALL", "ja_JP.UTF-8") + .env("LC_MESSAGES", "ja_JP.UTF-8") + .env("LANG", "ja_JP.UTF-8") + .output() + .unwrap() + }; + + let list = localized(&["known-daemons"]); + assert_success(&list); + let list = String::from_utf8(list.stdout).unwrap(); + assert!(list.contains("\t登録済み\n"), "{list}"); + assert!(list.contains("\t信頼済み搬送路\n"), "{list}"); + assert!(!list.contains("\tenrolled\n"), "{list}"); + assert!(!list.contains("\tcarrier\n"), "{list}"); + + let forget = localized(&["known-daemons", "forget", "enrolled-fingerprint"]); + assert_success(&forget); + let forget = String::from_utf8(forget.stdout).unwrap(); + assert_eq!(forget, "デーモン enrolled-fingerprint を削除しました。\n"); + + let empty_dir = unique_temp_dir("known-daemon-empty-locale"); + let empty = Command::new(bin()) + .args(["known-daemons", "--state-dir"]) + .arg(&empty_dir) + .env("LC_ALL", "ja_JP.UTF-8") + .env("LC_MESSAGES", "ja_JP.UTF-8") + .env("LANG", "ja_JP.UTF-8") + .output() + .unwrap(); + assert_success(&empty); + assert_eq!(String::from_utf8(empty.stdout).unwrap(), "登録済みのデーモンはありません。\n"); + + fs::remove_dir_all(dir).unwrap(); + fs::remove_dir_all(empty_dir).unwrap(); +} + +#[test] +fn noun_first_ratio_commands_reject_nonfinite_values_before_connecting() { + const PANE: &str = "pane_11111111111111111111111111111111"; + const SPLIT: &str = "split_22222222222222222222222222222222"; + for args in [ + ["pane", PANE, "split", "--right", "--ratio", "NaN"].as_slice(), + ["pane", PANE, "split", "ratio", "set", "--split", SPLIT, "--ratio", "NaN"].as_slice(), + ] { + let output = Command::new(bin()) + .env("LC_ALL", "ja_JP.UTF-8") + .env("LC_MESSAGES", "ja_JP.UTF-8") + .env("LANG", "ja_JP.UTF-8") + .args(args) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.starts_with("cmux: "), "{stderr}"); + assert!(stderr.contains("--ratio must be greater than 0 and less than 1"), "{stderr}"); + assert!(!stderr.contains("cmux-tui"), "{stderr}"); + } +} + +#[test] +fn noun_first_viewport_width_rejects_invalid_values_before_connecting() { + const PANE: &str = "pane_11111111111111111111111111111111"; + for (args, expected) in [ + ( + ["pane", PANE, "split", "--right", "--viewport-width", "NaN"].as_slice(), + "--viewport-width must be from 0.1 through 1", + ), + ( + ["pane", PANE, "split", "--right", "--viewport-width", "0.09"].as_slice(), + "--viewport-width must be from 0.1 through 1", + ), + ( + ["pane", PANE, "split", "--down", "--viewport-width", "0.5"].as_slice(), + "--viewport-width requires --right", + ), + ] { + let output = Command::new(bin()).args(args).output().unwrap(); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.starts_with("cmux: "), "{stderr}"); + assert!(stderr.contains(expected), "{stderr}"); + assert!(!stderr.contains("cmux-tui"), "{stderr}"); + } +} + +#[cfg(unix)] +struct PtyChild { + child: Option>, + output_drain: Option>, +} + +#[cfg(unix)] +struct TestTempDir(PathBuf); + +#[cfg(unix)] +impl TestTempDir { + fn create(name: &str) -> Self { + let path = unique_temp_dir(name); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &std::path::Path { + &self.0 + } +} + +#[cfg(unix)] +impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[cfg(unix)] +impl PtyChild { + fn start(args: &[&str]) -> Self { + Self::start_with_env(args, &[]) + } + + fn start_with_env(args: &[&str], env: &[(&str, &std::ffi::OsStr)]) -> Self { + let spawned = spawn_pty_child(args, env); + let mut master = spawned.master.try_clone_reader().unwrap(); + let output_drain = std::thread::spawn(move || { + let mut buffer = [0; 8192]; + while master.read(&mut buffer).is_ok_and(|read| read > 0) {} + }); + Self { child: Some(spawned.child), output_drain: Some(output_drain) } + } + + fn wait_for_exit(&mut self, timeout: Duration) -> Option { + let mut child = self.child.take().expect("PTY child already has an exit waiter"); + let mut killer = child.clone_killer(); + let (sender, receiver) = mpsc::sync_channel(1); + let _waiter = std::thread::spawn(move || { + let _ = sender.send(child.wait()); + }); + match receiver.recv_timeout(timeout) { + Ok(status) => Some(status.unwrap()), + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = killer.kill(); + match receiver.recv_timeout(Duration::from_secs(5)) { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + panic!("interactive owner did not exit cleanly after kill: {error}"); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = self.output_drain.take(); + panic!("interactive owner did not exit after kill"); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = self.output_drain.take(); + panic!("interactive owner exit waiter disconnected after kill"); + } + } + None + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = self.output_drain.take(); + panic!("interactive owner exit waiter disconnected") + } + } + } +} + +#[cfg(unix)] +impl Drop for PtyChild { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(output_drain) = self.output_drain.take() { + let _ = output_drain.join(); + } + } +} + +#[cfg(unix)] +struct DisconnectablePtyChild { + child: Box, + master: Option>, +} + +#[cfg(unix)] +impl DisconnectablePtyChild { + fn start(args: &[&str]) -> Self { + let spawned = spawn_pty_child(args, &[]); + Self { child: spawned.child, master: Some(spawned.master) } + } + + fn disconnect_host_terminal(&mut self) { + self.master.take(); + } +} + +#[cfg(unix)] +fn spawn_pty_child(args: &[&str], env: &[(&str, &std::ffi::OsStr)]) -> cmux_pty::SpawnedPty { + let pair = + cmux_pty::open(cmux_pty::PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 }) + .unwrap(); + let mut command = cmux_pty::PtyCommand::new(bin()); + command.args(args.iter().copied()); + command.env_clear(); + for (key, value) in std::env::vars() { + if key != "CMUX_TUI_SOCKET" { + command.env(key, value); + } + } + for (key, value) in env { + command.env(*key, value.to_string_lossy()); + } + pair.spawn(command).unwrap() +} + +#[cfg(unix)] +fn plain_tui_is_ready(server: &HeadlessServer) -> bool { + let clients = json_cli(server, &["client", "list"]); + if !clients.status.success() + || !json_output(&clients).as_array().is_some_and(|clients| { + clients.iter().any(|client| client["client_kind"].as_str() == Some("tui")) + }) + { + return false; + } - let reset = Command::new(bin()) - .args([ - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - confirm_reset, - "--state", - ]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() - .unwrap(); - assert!(!reset.status.success(), "reset unexpectedly succeeded"); - assert!(database.exists(), "reset removed the registry while a live marker was present"); - assert!(live_marker.exists(), "reset removed the orphan live marker"); + let terminals = json_cli(server, &["terminal", "list"]); + terminals.status.success() + && json_output(&terminals).as_array().is_some_and(|terminals| !terminals.is_empty()) +} - fs::remove_dir_all(dir).unwrap(); +#[cfg(unix)] +impl Drop for DisconnectablePtyChild { + fn drop(&mut self) { + self.disconnect_host_terminal(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } } -#[cfg(any(target_os = "ios", target_os = "macos", target_os = "linux", target_os = "android"))] +#[cfg(unix)] #[test] -fn session_reset_state_removes_dead_orphan_terminal_host_live_marker() { - let dir = unique_temp_dir("session-reset-dead-orphan-host-marker"); +fn startup_does_not_invoke_external_ghostty_config_resolver() { + let dir = unique_temp_dir("external-ghostty-config-resolver"); fs::create_dir_all(&dir).unwrap(); - let state = dir.join("state"); - let session = "schema-reset-dead-orphan-host"; - - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let database = find_session_database(&state, session); - let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); - fs::create_dir_all(&host_root).unwrap(); - fs::set_permissions(&host_root, fs::Permissions::from_mode(0o700)).unwrap(); - let live_marker = host_root.join("orphan.live"); - fs::OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .mode(0o600) - .open(&live_marker) - .unwrap(); - let preview = Command::new(bin()) - .args(["--json", "session", session, "reset-state", "--state"]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() - .unwrap(); - assert_success(&preview); - let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); - let confirm_reset = preview["confirm_reset"].as_str().unwrap(); + let helper = dir.join("ghostty-config-probe"); + let capture = dir.join("resolver-invoked.txt"); + let socket = dir.join("mux.sock"); + fs::write( + &helper, + r#"#!/bin/sh +echo invoked > "$CMUX_TEST_GHOSTTY_CAPTURE" +exit 0 +"#, + ) + .unwrap(); + fs::set_permissions(&helper, fs::Permissions::from_mode(0o700)).unwrap(); - let reset = Command::new(bin()) - .args([ - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - confirm_reset, - "--state", - ]) - .arg(&state) + let output = Command::new(bin()) + .args(["--machine-provider", "/does/not/exist", "--headless", "--socket"]) + .arg(&socket) + .env("GHOSTTY_BIN", &helper) + .env("CMUX_TEST_GHOSTTY_CAPTURE", &capture) + .env("CMUX_MACHINE_PROVIDER_TOKEN", "edge-test-bearer") + .env("CMUX_PROVIDER_WORKSPACE_AUTHORITY", "provider-workspace-authority-test-00000001") .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert_success(&reset); - assert!(!database.exists(), "reset left the registry after removing a dead live marker"); - assert!(!host_root.exists(), "reset left terminal-host state after removing a dead marker"); + assert!(!output.status.success(), "conflicting provider launch unexpectedly succeeded"); + assert!(!capture.exists(), "startup invoked external Ghostty config resolver"); fs::remove_dir_all(dir).unwrap(); } #[cfg(unix)] #[test] -fn session_reset_state_rejects_stale_preview_when_targets_change() { - let dir = unique_temp_dir("session-reset-stale-preview"); - fs::create_dir_all(&dir).unwrap(); - let state = dir.join("state"); - let session = "schema-reset-stale-preview"; - - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let database = find_session_database(&state, session); - let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); - assert!(!host_root.exists()); +fn plain_launch_attaches_to_existing_local_session() { + let server = HeadlessServer::start("plain-launch-attach"); + let ready_path = server.dir.join("tui-initial-workspace-ready"); + let mut ready = TestFifoSignal::create(&ready_path); + let mut tui = PtyChild::start_with_env( + &["--socket", server.socket.to_str().unwrap()], + &[("CMUX_TUI_TEST_INITIAL_WORKSPACE_READY_SIGNAL", ready_path.as_os_str())], + ); - let preview = Command::new(bin()) - .args(["--json", "session", session, "reset-state", "--state"]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() - .unwrap(); - assert_success(&preview); - let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); - let stale_confirm_reset = preview["confirm_reset"].as_str().unwrap(); + ready.receive(Duration::from_secs(10)); + if let Some(status) = tui.child.as_mut().unwrap().try_wait().unwrap() { + panic!("plain launch exited instead of attaching: {status}"); + } + let clients = json_cli(&server, &["client", "list"]); + assert_success(&clients); + assert!( + json_output(&clients) + .as_array() + .unwrap() + .iter() + .any(|client| client["client_kind"].as_str() == Some("tui")), + "plain launch readiness signal preceded server client state" + ); +} - fs::create_dir_all(&host_root).unwrap(); - fs::set_permissions(&host_root, fs::Permissions::from_mode(0o700)).unwrap(); - fs::write(host_root.join("sentinel"), b"new-host-state").unwrap(); +#[cfg(target_os = "linux")] +#[test] +fn plain_launch_preserves_the_existing_session_connect_deadline() { + let dir = unique_temp_dir("saturated-existing-session"); + fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("mux.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + // SAFETY: listener owns a valid listening Unix socket. Reapplying listen + // only narrows its pending connection queue for this isolated fixture. + assert_eq!(unsafe { libc::listen(listener.as_raw_fd(), 0) }, 0); + + let mut queued = Vec::new(); + for _ in 0..32 { + match transport::connect_until(&socket, Instant::now() + Duration::from_millis(75)) { + Ok(stream) => queued.push(stream), + Err(error) => { + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + break; + } + } + } + assert!(queued.len() < 32, "could not saturate the existing session listener"); - let reset = Command::new(bin()) - .args([ - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - stale_confirm_reset, - "--state", - ]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() + let mut launch = Command::new(bin()) + .args(["--ephemeral", "--socket"]) + .arg(&socket) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() .unwrap(); - assert!(!reset.status.success(), "reset accepted a stale preview token"); - assert!(database.exists(), "reset removed the registry with a stale token"); - assert!(host_root.join("sentinel").exists(), "reset removed newly appeared host state"); + let status = wait_for_child_exit( + &mut launch, + Duration::from_secs(3), + "plain launch exceeded the existing-session connection deadline", + ); + assert!(!status.success(), "plain launch replaced a live saturated session"); + assert!(socket.exists(), "plain launch unlinked the live saturated session socket"); + drop(queued); + drop(listener); + fs::remove_file(socket).unwrap(); fs::remove_dir_all(dir).unwrap(); } -#[cfg(unix)] #[test] -fn session_reset_state_rejects_preview_for_recreated_registry() { - let dir = unique_temp_dir("session-reset-recreated-registry"); +fn plain_launch_reports_an_incompatible_server_without_reconnecting() { + let dir = unique_temp_dir("incompatible-server"); fs::create_dir_all(&dir).unwrap(); - let state = dir.join("state"); - let session = "schema-reset-recreated-registry"; - - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let original_database = find_session_database(&state, session); - let session_dir = original_database.parent().unwrap().to_path_buf(); - - let preview = Command::new(bin()) - .args(["--json", "session", session, "reset-state", "--state"]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() - .unwrap(); - assert_success(&preview); - let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); - let stale_confirm_reset = preview["confirm_reset"].as_str().unwrap(); - - fs::remove_dir_all(&session_dir).unwrap(); - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let recreated_database = find_session_database(&state, session); + let socket = dir.join("mux.sock"); + let listener = transport::listen(&socket).unwrap(); + let (accepted_tx, accepted_rx) = mpsc::sync_channel(1); + let server = std::thread::spawn(move || { + let mut stream = listener.accept().unwrap(); + let mut request = String::new(); + BufReader::new(stream.try_clone_box().unwrap()).read_line(&mut request).unwrap(); + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + let response = serde_json::json!({ + "id": request["id"], + "ok": true, + "data": { + "app": "cmux-tui", + "session": "test", + "pid": 4242, + "version": "0.0.0-stale", + "protocol": 8, + }, + }); + writeln!(stream, "{response}").unwrap(); + let _cleanup_or_duplicate = listener.accept().unwrap(); + accepted_tx.send(()).unwrap(); + }); - let reset = Command::new(bin()) - .args([ - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - stale_confirm_reset, - "--state", - ]) - .arg(&state) + let output = Command::new(bin()) + .args(["--socket"]) + .arg(&socket) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert!(!reset.status.success(), "reset accepted a token from a replaced registry"); - assert!(recreated_database.exists(), "reset removed the recreated registry"); + let reconnected = accepted_rx.recv_timeout(Duration::from_millis(250)).is_ok(); + if !reconnected { + let _cleanup = transport::connect(&socket).unwrap(); + accepted_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + } + server.join().unwrap(); + let _ = fs::remove_file(&socket); + let _ = fs::remove_dir_all(&dir); - fs::remove_dir_all(dir).unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(!reconnected, "plain startup opened a second connection after identifying the server"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("server: v0.0.0-stale protocol 8"), "{stderr}"); + assert!( + stderr.contains(&format!( + "client: v{} protocol {}", + env!("CARGO_PKG_VERSION"), + cmux_tui_core::server::PROTOCOL_VERSION + )), + "{stderr}" + ); + assert!(stderr.contains("Stopping exits pane processes."), "{stderr}"); + assert!(stderr.contains("cmux-tui server stop"), "{stderr}"); + assert!(!stderr.contains("already in use"), "{stderr}"); } -#[cfg(unix)] #[test] -fn session_reset_state_rejects_preview_when_session_sidecar_appears() { - let dir = unique_temp_dir("session-reset-new-sidecar"); +fn incompatible_server_status_and_public_api_use_separate_compatibility_domains() { + let dir = unique_temp_dir("incompatible-cli"); fs::create_dir_all(&dir).unwrap(); - let state = dir.join("state"); - let session = "schema-reset-new-sidecar"; - - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let database = find_session_database(&state, session); - let session_dir = database.parent().unwrap(); + let socket = dir.join("mux.sock"); + let listener = transport::listen(&socket).unwrap(); + let server = std::thread::spawn(move || { + let mut commands = Vec::new(); + for _ in 0..4 { + let mut stream = listener.accept().unwrap(); + let mut request = String::new(); + BufReader::new(stream.try_clone_box().unwrap()).read_line(&mut request).unwrap(); + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + let response = if let Some(command) = request["cmd"].as_str() { + commands.push(command.to_string()); + serde_json::json!({ + "id": request["id"], + "ok": true, + "data": { + "app": "cmux-tui", + "session": "test", + "pid": 4242, + "version": "0.0.0-stale", + "protocol": cmux_tui_core::server::PROTOCOL_VERSION, + }, + }) + } else { + commands.push(request["operation"].as_str().unwrap().to_string()); + serde_json::json!({ + "protocol": cmux_tui_core::resource::PROTOCOL, + "type": "response", + "id": request["id"], + "ok": true, + "result": {"alive": true}, + }) + }; + writeln!(stream, "{response}").unwrap(); + } + commands + }); - let preview = Command::new(bin()) - .args(["--json", "session", session, "reset-state", "--state"]) - .arg(&state) + let status = Command::new(bin()) + .args(["server", "status", "--socket"]) + .arg(&socket) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert_success(&preview); - let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); - let stale_confirm_reset = preview["confirm_reset"].as_str().unwrap(); - - let sidecar = session_dir.join("workspace-registry.sqlite3-wal"); - fs::write(&sidecar, b"new-sidecar-state").unwrap(); - - let reset = Command::new(bin()) - .args([ - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - stale_confirm_reset, - "--state", - ]) - .arg(&state) + assert_success(&status); + let status_text = String::from_utf8_lossy(&status.stdout); + assert!(status_text.contains("status: incompatible")); + assert!(status_text.contains("reason: distribution version differs")); + assert!(status_text.contains("source build differs")); + assert!(status_text.contains("terminal engine build differs")); + + let status_json = Command::new(bin()) + .args(["--json", "server", "status", "--socket"]) + .arg(&socket) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert!(!reset.status.success(), "reset accepted a token before a new sidecar existed"); - assert!(database.exists(), "reset removed the registry with a stale token"); - assert!(sidecar.exists(), "reset removed a sidecar that was not previewed"); - - fs::remove_dir_all(dir).unwrap(); -} - -#[test] -fn session_reset_state_rejects_preview_when_nested_session_file_appears() { - let dir = unique_temp_dir("session-reset-new-nested-file"); - fs::create_dir_all(&dir).unwrap(); - let state = dir.join("state"); - let session = "schema-reset-new-nested-file"; - - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let database = find_session_database(&state, session); - let session_dir = database.parent().unwrap(); - let nested_dir = session_dir.join("nested"); - fs::create_dir_all(&nested_dir).unwrap(); - fs::write(nested_dir.join("previewed"), b"old").unwrap(); + assert_success(&status_json); + let status_json: serde_json::Value = serde_json::from_slice(&status_json.stdout).unwrap(); + assert_eq!( + status_json["mismatch_reasons"], + serde_json::json!(["distribution-version", "source-build", "terminal-engine"]) + ); - let preview = Command::new(bin()) - .args(["--json", "session", session, "reset-state", "--state"]) - .arg(&state) + let identify = Command::new(bin()) + .args(["--json", "--socket"]) + .arg(&socket) + .args(["raw", "command", "--request-json", r#"{"id":"identify","cmd":"identify"}"#]) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert_success(&preview); - let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); - let stale_confirm_reset = preview["confirm_reset"].as_str().unwrap(); - - let unpreviewed = nested_dir.join("unpreviewed"); - fs::write(&unpreviewed, b"new").unwrap(); + assert_success(&identify); + assert_eq!(json_output(&identify)["version"], "0.0.0-stale"); - let reset = Command::new(bin()) - .args([ - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - stale_confirm_reset, - "--state", - ]) - .arg(&state) + let ping = Command::new(bin()) + .args(["--json", "--socket"]) + .arg(&socket) + .args(["session", "current", "ping"]) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert!(!reset.status.success(), "reset accepted a token before a nested file existed"); - assert!(database.exists(), "reset removed the registry with a stale token"); - assert!(unpreviewed.exists(), "reset removed a nested file that was not previewed"); + assert_success(&ping); + assert_eq!(json_output(&ping)["alive"], true); - fs::remove_dir_all(dir).unwrap(); + assert_eq!(server.join().unwrap(), ["identify", "identify", "identify", "session.ping"]); + let _ = fs::remove_file(&socket); + let _ = fs::remove_dir_all(&dir); } #[cfg(unix)] #[test] -fn session_reset_state_bad_token_does_not_mutate_state_root() { - let dir = unique_temp_dir("session-reset-bad-token-no-mutation"); +fn packaged_launcher_name_is_used_in_upgrade_instructions() { + let dir = unique_temp_dir("packaged-launcher-message"); fs::create_dir_all(&dir).unwrap(); - let state = dir.join("state"); - fs::create_dir_all(&state).unwrap(); - fs::set_permissions(&state, fs::Permissions::from_mode(0o755)).unwrap(); - let session = "schema-reset-bad-token"; - let resetter = cmux_tui_core::PersistentSessionStateResetter::new(state.clone()); - let session_dir = resetter.session_dir(session); - fs::create_dir_all(&session_dir).unwrap(); + let socket = dir.join("mux.sock"); + let listener = transport::listen(&socket).unwrap(); + let server = std::thread::spawn(move || { + let mut stream = listener.accept().unwrap(); + let mut request = String::new(); + BufReader::new(stream.try_clone_box().unwrap()).read_line(&mut request).unwrap(); + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + let response = serde_json::json!({ + "id": request["id"], + "ok": true, + "data": { + "app": "cmux-tui", + "session": "test", + "pid": 4242, + "version": "0.0.0-stale", + "protocol": cmux_tui_core::server::PROTOCOL_VERSION, + }, + }); + writeln!(stream, "{response}").unwrap(); + }); - let reset = Command::new(bin()) - .args([ - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - "wrong-token", - "--state", - ]) - .arg(&state) + let output = Command::new(bin()) + .arg0("cmux") + .args(["--socket"]) + .arg(&socket) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert!(!reset.status.success(), "reset accepted a wrong confirmation token"); - assert!(!state.join("session-locks").exists(), "wrong-token reset created session locks"); - assert_eq!(fs::metadata(&state).unwrap().permissions().mode() & 0o777, 0o755); - fs::remove_dir_all(dir).unwrap(); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("cmux server stop"), "{stderr}"); + assert!(stderr.contains("then rerun the previous command using `cmux`."), "{stderr}"); + server.join().unwrap(); + let _ = fs::remove_file(&socket); + let _ = fs::remove_dir_all(&dir); } -#[cfg(unix)] #[test] -fn session_reset_state_rejects_symlinked_terminal_host_state() { - let dir = unique_temp_dir("session-reset-symlink-host"); - fs::create_dir_all(&dir).unwrap(); - let state = dir.join("state"); - let session = "schema-reset-symlink-host"; +fn server_status_and_stop_control_a_compatible_headless_session() { + let mut server = HeadlessServer::start("server-lifecycle"); + let status = cli(&server, &["--json", "server", "status"]); + assert_success(&status); + let status: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap(); + assert_eq!(status["running"].as_bool(), Some(true)); + assert_eq!(status["compatible"].as_bool(), Some(true)); + assert_eq!(status["mismatch_reasons"], serde_json::json!([])); + assert_eq!( + status["server"]["version"].as_str(), + Some(cmux_tui_core::release::distribution_version()) + ); - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let database = find_session_database(&state, session); - let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); - let outside_host_root = dir.join("outside-host-state"); - fs::create_dir_all(&outside_host_root).unwrap(); - fs::set_permissions(&outside_host_root, fs::Permissions::from_mode(0o700)).unwrap(); - let outside_sentinel = outside_host_root.join("sentinel"); - fs::write(&outside_sentinel, b"outside").unwrap(); - symlink(&outside_host_root, &host_root).unwrap(); + let stop = cli(&server, &["--json", "server", "stop"]); + assert_success(&stop); + assert_eq!( + serde_json::from_slice::(&stop.stdout).unwrap(), + serde_json::json!({"stopped": true}) + ); + assert!( + String::from_utf8_lossy(&stop.stderr).contains("Stopping exits pane processes."), + "direct server stop omitted its destructive-operation warning: {}", + String::from_utf8_lossy(&stop.stderr) + ); + assert!(server.child.wait().unwrap().success()); + assert!(!server.socket.exists()); +} - let preview = Command::new(bin()) - .args(["--json", "session", session, "reset-state", "--state"]) - .arg(&state) +#[test] +fn server_lifecycle_help_and_machine_rejection_are_localized() { + let help = Command::new(bin()) + .env("LC_ALL", "ja_JP.UTF-8") + .env("LC_MESSAGES", "ja_JP.UTF-8") + .env("LANG", "ja_JP.UTF-8") + .args(["server", "--help"]) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert_success(&preview); - let preview: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); - let confirm_reset = preview["confirm_reset"].as_str().unwrap(); + assert_success(&help); + let help = String::from_utf8(help.stdout).unwrap(); + assert!(help.contains("使用方法"), "{help}"); + assert!(help.contains("別のリリース"), "{help}"); + assert!(!help.contains("These local lifecycle commands"), "{help}"); - let reset = Command::new(bin()) - .args([ - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - confirm_reset, - "--state", - ]) - .arg(&state) + let rejected = Command::new(bin()) + .env("LC_ALL", "ja_JP.UTF-8") + .env("LC_MESSAGES", "ja_JP.UTF-8") + .env("LANG", "ja_JP.UTF-8") + .args(["--machine", "remote", "server", "status"]) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert!(!reset.status.success(), "reset accepted a symlinked host root"); - assert!(database.exists(), "reset removed the registry after rejecting host root"); - assert!(fs::symlink_metadata(&host_root).unwrap().file_type().is_symlink()); - assert!(outside_sentinel.exists(), "reset mutated the symlink target"); - - fs::remove_dir_all(dir).unwrap(); + assert_eq!(rejected.status.code(), Some(2)); + let rejected = String::from_utf8(rejected.stderr).unwrap(); + assert!(rejected.contains("--machine を使用できません"), "{rejected}"); + assert!(!rejected.contains("lifecycle commands cannot use"), "{rejected}"); } #[cfg(unix)] #[test] -fn session_reset_state_rejects_symlinked_state_root() { - let dir = unique_temp_dir("session-reset-symlink-root"); +fn signal_termination_completes_server_shutdown() { + let dir = unique_temp_dir("server-signal-shutdown"); fs::create_dir_all(&dir).unwrap(); - let actual_state = dir.join("actual-state"); - let state = dir.join("state-link"); - let session = "schema-reset-symlink-root"; - fs::create_dir_all(&actual_state).unwrap(); - fs::write(actual_state.join("root-sentinel"), b"keep").unwrap(); - symlink(&actual_state, &state).unwrap(); - - drop(cmux_tui_core::WorkspaceRegistry::open(&state, session).unwrap()); - let database = find_session_database(&actual_state, session); - let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&state, session); - fs::create_dir_all(&host_root).unwrap(); - fs::write(host_root.join("orphaned-sidecar"), b"stale").unwrap(); - - let preview = Command::new(bin()) - .args(["--json", "session", session, "reset-state", "--state"]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() + let socket = dir.join("mux.sock"); + let mut server = Command::new(bin()) + .args(["--headless", "--ephemeral", "--socket"]) + .arg(&socket) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() .unwrap(); - assert!(!preview.status.success(), "preview accepted a symlinked state root"); - let preview_error: serde_json::Value = serde_json::from_slice(&preview.stderr).unwrap(); - assert_eq!(preview_error["code"], "session.reset_state.filesystem"); + wait_for_socket_path(&socket); - let reset = Command::new(bin()) - .args([ - "--json", - "session", - session, - "reset-state", - "--force", - "--confirm-reset", - "unused", - "--state", - ]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() + assert_eq!(unsafe { libc::kill(server.id() as libc::pid_t, libc::SIGTERM) }, 0); + let status = wait_for_child_exit( + &mut server, + Duration::from_secs(3), + "SIGTERM left the server waiting on an unrequested mux shutdown", + ); + let socket_remained = socket.exists(); + fs::remove_dir_all(dir).unwrap(); + + assert!(status.success(), "signal-driven shutdown exited with {status}"); + assert!(!socket_remained, "signal-driven shutdown retained its control socket"); +} + +#[cfg(unix)] +#[test] +fn invalid_websocket_startup_returns_after_publishing_the_local_socket() { + let dir = unique_temp_dir("invalid-websocket-startup"); + fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("mux.sock"); + let published_path = dir.join("published"); + let mut published = TestFifoSignal::create(&published_path); + let ghostty = dir.join("ghostty"); + fs::write(&ghostty, "#!/bin/sh\nprintf 'background = #272822\\nforeground = #fdfff1\\n'\n") + .unwrap(); + fs::set_permissions(&ghostty, fs::Permissions::from_mode(0o700)).unwrap(); + let mut server = Command::new(bin()) + .args(["--headless", "--ephemeral", "--socket"]) + .arg(&socket) + .args(["--ws", "not-an-address"]) + .env("GHOSTTY_BIN", &ghostty) + .env("CMUX_TUI_TEST_LOCAL_SOCKET_PUBLISHED_MARKER", &published_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() .unwrap(); - assert!(!reset.status.success(), "reset accepted a symlinked state root"); - let reset_error: serde_json::Value = serde_json::from_slice(&reset.stderr).unwrap(); - assert_eq!(reset_error["code"], "session.reset_state.filesystem"); - assert!(database.exists(), "reset removed stale database through symlinked state root"); - assert!(host_root.exists(), "reset removed terminal-host state through symlinked state root"); - assert!(fs::symlink_metadata(&state).unwrap().file_type().is_symlink()); - assert!(actual_state.join("root-sentinel").exists(), "reset removed root sibling data"); + published.receive(Duration::from_secs(15)); + let status = wait_for_child_exit( + &mut server, + Duration::from_secs(3), + "invalid WebSocket setup entered the running-server shutdown wait", + ); + let socket_remained = socket.exists(); + let mut stderr = String::new(); + server.stderr.take().unwrap().read_to_string(&mut stderr).unwrap(); fs::remove_dir_all(dir).unwrap(); + + assert!(!status.success(), "invalid WebSocket setup unexpectedly succeeded"); + assert!(stderr.contains("invalid WebSocket address"), "{stderr}"); + assert!(!socket_remained, "failed startup retained its local control socket"); } +#[cfg(unix)] #[test] -fn session_reset_state_missing_target_does_not_mutate_state_root() { - let dir = unique_temp_dir("session-reset-missing-target"); +fn server_shutdown_exits_when_the_interactive_driver_cannot_progress() { + let dir = unique_temp_dir("server-stop-blocked-interactive-driver"); fs::create_dir_all(&dir).unwrap(); - let state = dir.join("not-cmux-state"); - fs::create_dir_all(&state).unwrap(); - #[cfg(unix)] - fs::set_permissions(&state, fs::Permissions::from_mode(0o755)).unwrap(); - fs::write(state.join("sentinel"), b"keep").unwrap(); + let socket = dir.join("mux.sock"); + let state = dir.join("state"); + let mut server = PtyChild::start_with_env( + &["--socket", socket.to_str().unwrap(), "--state", state.to_str().unwrap()], + &[ + ("CMUX_TUI_TEST_BLOCK_INTERACTIVE_DRIVER", std::ffi::OsStr::new("1")), + ("CMUX_TUI_TEST_SHUTDOWN_EXIT_GRACE_MS", std::ffi::OsStr::new("100")), + ], + ); + wait_for_socket_path(&socket); - let reset = Command::new(bin()) - .args(["--json", "session", "missing-session", "reset-state", "--force", "--state"]) - .arg(&state) - .env_remove("CMUX_TUI_SOCKET") - .output() - .unwrap(); - assert_success(&reset); - let reset: serde_json::Value = serde_json::from_slice(&reset.stdout).unwrap(); - assert_eq!(reset["removed_session_state"], false); - assert_eq!(reset["removed_terminal_hosts"], false); - assert!(state.join("sentinel").exists()); - assert!(!state.join("session-locks").exists()); - #[cfg(unix)] - assert_eq!(fs::metadata(&state).unwrap().permissions().mode() & 0o777, 0o755); + let response = json_socket_request(&socket, serde_json::json!({"id": 1, "cmd": "shutdown"})); + assert_eq!(response, serde_json::json!({})); + let status = server + .wait_for_exit(Duration::from_secs(3)) + .expect("server remained alive after acknowledging shutdown"); + assert!(status.success(), "server exited with {status}"); + assert!(transport::connect(&socket).is_err()); + drop(server); fs::remove_dir_all(dir).unwrap(); } +#[cfg(unix)] #[test] -fn durable_registry_survives_sigkill_and_rejects_a_second_writer() { - let dir = unique_temp_dir("durable-restart"); +fn forced_exit_waits_for_remote_runtime_cleanup() { + let dir = unique_temp_dir("remote-cleanup-fence"); fs::create_dir_all(&dir).unwrap(); let socket = dir.join("mux.sock"); - let second_socket = dir.join("second.sock"); let state = dir.join("state"); - let spawn = |socket: &std::path::Path| { - Command::new(bin()) - .args(["--headless", "--session", "durable", "--socket"]) - .arg(socket) - .arg("--state") - .arg(&state) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .unwrap() - }; + let remote_state = dir.join("remote-state"); + let remote_started_path = dir.join("remote-started"); + let remote_shutdown_started_path = dir.join("remote-shutdown-started"); + let remote_shutdown_release_path = dir.join("remote-shutdown-release"); + let remote_shutdown_complete_path = dir.join("remote-shutdown-complete"); + let mut remote_started = TestFifoSignal::create(&remote_started_path); + let mut remote_shutdown_started = TestFifoSignal::create(&remote_shutdown_started_path); + let mut remote_shutdown_release = TestFifoSignal::create(&remote_shutdown_release_path); + let mut remote_shutdown_complete = TestFifoSignal::create(&remote_shutdown_complete_path); + let mut server = PtyChild::start_with_env( + &[ + "--socket", + socket.to_str().unwrap(), + "--state", + state.to_str().unwrap(), + "--remote", + "--remote-state-dir", + remote_state.to_str().unwrap(), + ], + &[ + ("CMUX_TUI_TEST_BLOCK_INTERACTIVE_DRIVER", std::ffi::OsStr::new("1")), + ("CMUX_TUI_TEST_SHUTDOWN_EXIT_GRACE_MS", std::ffi::OsStr::new("100")), + ("CMUX_TUI_TEST_REMOTE_RUNTIME_STARTED_MARKER", remote_started_path.as_os_str()), + ( + "CMUX_TUI_TEST_REMOTE_SHUTDOWN_STARTED_SIGNAL", + remote_shutdown_started_path.as_os_str(), + ), + ("CMUX_TUI_TEST_REMOTE_SHUTDOWN_GATE", remote_shutdown_release_path.as_os_str()), + ( + "CMUX_TUI_TEST_REMOTE_SHUTDOWN_COMPLETE_SIGNAL", + remote_shutdown_complete_path.as_os_str(), + ), + ], + ); + wait_for_socket_path(&socket); + remote_started.receive(Duration::from_secs(10)); - let mut first = spawn(&socket); + let response = json_socket_request(&socket, serde_json::json!({"id": 1, "cmd": "shutdown"})); + assert_eq!(response, serde_json::json!({})); + remote_shutdown_started.receive(Duration::from_secs(3)); + assert!( + server.child.as_mut().unwrap().try_wait().unwrap().is_none(), + "server forced exit before remote runtime cleanup completed" + ); + + remote_shutdown_release.send(); + remote_shutdown_complete.receive(Duration::from_secs(5)); + let status = server + .wait_for_exit(Duration::from_secs(5)) + .expect("server remained alive after remote runtime cleanup completed"); + assert!(status.success(), "server exited with {status}"); + drop(server); + fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn daemon_handoff_cleans_local_ptys_before_forcing_a_blocked_interactive_driver_to_exit() { + let dir = unique_temp_dir("daemon-handoff-blocked-interactive-driver"); + fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("mux.sock"); + let pid_file = dir.join("local-pty.pid"); + let pid_published_path = dir.join("local-pty-published"); + let mut pid_published = TestFifoSignal::create(&pid_published_path); + let mut server = PtyChild::start_with_env( + &["--socket", socket.to_str().unwrap(), "--ephemeral"], + &[ + ("CMUX_TUI_TEST_BLOCK_INTERACTIVE_DRIVER", std::ffi::OsStr::new("1")), + ("CMUX_TUI_TEST_SHUTDOWN_EXIT_GRACE_MS", std::ffi::OsStr::new("100")), + ], + ); wait_for_socket_path(&socket); - let identify = json_socket_request(&socket, serde_json::json!({"id":1,"cmd":"identify"})); - let registry_id = identify["registry_id"].as_str().unwrap().to_string(); - let generation = identify["generation"].as_str().unwrap().to_string(); - let created = json_socket_request( + let command = format!( + "trap '' HUP TERM; echo $$ > {}; printf 1 > {}; while :; do sleep 1; done", + pid_file.display(), + pid_published_path.display() + ); + let run = raw_cli_command( &socket, serde_json::json!({ - "id":2, - "cmd":"create-workspace", - "name":"survivor", - "key":"018f6e21-7b70-7e70-8000-000000000044", - "origin":"process-test", - "mutation_id":"create-durable", - "expected_revision":0, + "id": "daemon-handoff-local-pty", + "cmd": "run", + "new_workspace": true, + "command": command, + }), + ) + .output() + .unwrap(); + assert_success(&run); + pid_published.receive(Duration::from_secs(5)); + let local_pid = fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap(); + let identity = json_socket_request(&socket, serde_json::json!({"id": 1, "cmd": "identify"})); + + let response = json_socket_request( + &socket, + serde_json::json!({ + "id": 2, + "cmd": "shutdown-daemon", + "pid": identity["pid"], + "generation": identity["generation"], }), ); - assert_eq!(created["workspace_revision"], 1); + assert_eq!(response["accepted"], true); + + let status = server + .wait_for_exit(Duration::from_secs(3)) + .expect("server remained alive after acknowledging daemon handoff"); + assert!(status.success(), "server exited with {status}"); + assert!(transport::connect(&socket).is_err()); + let cleaned = !process_is_active(local_pid) && !process_group_is_active(local_pid); + if !cleaned { + let local_pid = libc::pid_t::try_from(local_pid).unwrap(); + // SAFETY: local_pid names the isolated PTY session created by this test. + unsafe { + libc::kill(-local_pid, libc::SIGKILL); + libc::kill(local_pid, libc::SIGKILL); + } + } + drop(server); + fs::remove_dir_all(dir).unwrap(); + assert!(cleaned, "forced interactive-driver exit abandoned its local PTY session"); +} - let mut second = spawn(&second_socket); - let second_status = second.wait().unwrap(); - assert!(!second_status.success()); - let mut second_stderr = String::new(); - second.stderr.take().unwrap().read_to_string(&mut second_stderr).unwrap(); - assert!(second_stderr.contains("already owned by another daemon"), "{second_stderr}"); +#[cfg(unix)] +#[test] +fn server_stop_cancels_a_blocked_terminal_host_launch() { + let blocked_path = unique_temp_dir("server-stop-blocked-launch").with_extension("blocked"); + let gate_path = unique_temp_dir("server-stop-blocked-launch").with_extension("gate"); + let mut blocked = TestFifoSignal::create(&blocked_path); + let _gate = TestFifoSignal::create(&gate_path); + let blocked_value = blocked_path.to_string_lossy().into_owned(); + let gate_value = gate_path.to_string_lossy().into_owned(); + let mut server = HeadlessServer::start_with( + "server-stop-blocked-launch", + false, + &[ + ("CMUX_TUI_TEST_BOOTSTRAP_READY_STARTED_SIGNAL", &blocked_value), + ("CMUX_TUI_TEST_BOOTSTRAP_READY_GATE", &gate_value), + ], + ); + let mut create = raw_cli_command( + &server.socket, + serde_json::json!({"id":"blocked-create","cmd":"new-workspace"}), + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&server.state, "main"); + blocked.receive(Duration::from_secs(5)); + assert!(create.try_wait().unwrap().is_none(), "terminal creation did not remain in flight"); - // Child::kill is SIGKILL on Unix, intentionally bypassing graceful - // cleanup and leaving the old socket behind. - first.kill().unwrap(); - first.wait().unwrap(); - let _ = fs::remove_file(&socket); + let stop_started = Instant::now(); + let stop = cli(&server, &["--json", "server", "stop"]); - let mut restarted = spawn(&socket); - wait_for_socket_path(&socket); - let recovered = - json_socket_request(&socket, serde_json::json!({"id":3,"cmd":"list-workspaces"})); - assert_eq!(recovered["registry_id"], registry_id); - assert_ne!(recovered["generation"], generation); - assert_eq!(recovered["workspace_revision"], 1); - assert_eq!(recovered["workspaces"][0]["key"], "018f6e21-7b70-7e70-8000-000000000044"); - assert_eq!(recovered["workspaces"][0]["name"], "survivor"); - assert!(recovered["workspaces"][0]["screens"].as_array().unwrap().is_empty()); + assert_success(&stop); + assert!( + stop_started.elapsed() < Duration::from_secs(2), + "shutdown waited for the blocked creator: {:?}", + stop_started.elapsed() + ); + assert!(server.child.wait().unwrap().success()); + assert!(!create.wait().unwrap().success()); + assert!(terminal_host_pids(&host_root).is_empty()); + fs::remove_file(blocked_path).unwrap(); + fs::remove_file(gate_path).unwrap(); +} - restarted.kill().unwrap(); - restarted.wait().unwrap(); - let _ = fs::remove_dir_all(dir); +#[cfg(unix)] +#[test] +fn server_stop_cancels_a_blocked_terminal_host_launch_write() { + let blocked_path = + unique_temp_dir("server-stop-blocked-launch-write").with_extension("blocked"); + let gate_path = unique_temp_dir("server-stop-blocked-launch-write").with_extension("gate"); + let mut blocked = TestFifoSignal::create(&blocked_path); + let _gate = TestFifoSignal::create(&gate_path); + let blocked_value = blocked_path.to_string_lossy().into_owned(); + let gate_value = gate_path.to_string_lossy().into_owned(); + let mut server = HeadlessServer::start_with( + "server-stop-blocked-launch-write", + false, + &[ + ("CMUX_TUI_TEST_LAUNCH_READ_STARTED_SIGNAL", &blocked_value), + ("CMUX_TUI_TEST_LAUNCH_READ_GATE", &gate_value), + ], + ); + // Linux caps one execve argument below 128 KiB even when the aggregate + // ARG_MAX is larger. This still exceeds the bootstrap pipe capacity, so + // the host blocks on the Launch write without failing the CLI spawn first. + let command = format!(": #{}", "x".repeat(96 * 1024)); + let mut create = raw_cli_command( + &server.socket, + serde_json::json!({ + "id": "blocked-launch-write", + "cmd": "run", + "new_workspace": true, + "command": command, + }), + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&server.state, "main"); + blocked.receive(Duration::from_secs(5)); + assert!(create.try_wait().unwrap().is_none(), "terminal creation did not block on Launch"); + + let stop_started = Instant::now(); + let stop = cli(&server, &["--json", "server", "stop"]); + let stop_elapsed = stop_started.elapsed(); + + assert_success(&stop); + assert!(server.child.wait().unwrap().success()); + assert!(!create.wait().unwrap().success()); + assert!(terminal_host_pids(&host_root).is_empty()); + assert!( + stop_elapsed < Duration::from_secs(2), + "shutdown waited for the blocked Launch write: {stop_elapsed:?}" + ); + fs::remove_file(blocked_path).unwrap(); + fs::remove_file(gate_path).unwrap(); } #[cfg(unix)] #[test] -fn machine_agent_is_a_real_entrypoint_without_changing_ordinary_cli_dispatch() { - let machine_agent = Command::new(bin()) - .env("LC_ALL", "C") - .env("LC_MESSAGES", "C") - .env("LANG", "C") - .args(["machine-agent", "--help"]) +fn cancelled_published_host_is_terminated_through_its_record() { + let barrier = unique_temp_dir("server-stop-published-launch").with_extension("barrier"); + let mut published = TestFifoSignal::create(&barrier); + let barrier_value = barrier.to_string_lossy().into_owned(); + let mut server = HeadlessServer::start_with( + "server-stop-published-launch", + false, + &[("CMUX_TUI_TEST_LAUNCH_ACK_BARRIER", &barrier_value)], + ); + let direct_pid_file = server.dir.join("published-direct.pid"); + let descendant_pid_file = server.dir.join("published-descendant.pid"); + let pids_published_path = server.dir.join("published-pids-ready"); + let mut pids_published = TestFifoSignal::create(&pids_published_path); + let command = format!( + "echo $$ > {}; trap '' HUP; (trap '' HUP; while :; do sleep 1; done) & echo $! > {}; printf 1 > {}; wait", + direct_pid_file.display(), + descendant_pid_file.display(), + pids_published_path.display() + ); + let mut create = raw_cli_command( + &server.socket, + serde_json::json!({ + "id": "published-host-create", + "cmd": "run", + "new_workspace": true, + "command": command, + }), + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&server.state, "main"); + published.receive(Duration::from_secs(5)); + pids_published.receive(Duration::from_secs(5)); + let direct_pid = fs::read_to_string(&direct_pid_file).unwrap().trim().parse().unwrap(); + let descendant_pid = fs::read_to_string(&descendant_pid_file).unwrap().trim().parse().unwrap(); + assert!(!terminal_host_pids(&host_root).is_empty()); + + let stop = cli(&server, &["--json", "server", "stop"]); + + assert_success(&stop); + assert!(server.child.wait().unwrap().success()); + assert!(!create.wait().unwrap().success()); + let _ = fs::remove_file(&barrier); + assert!(!process_is_active(direct_pid)); + assert!(!process_group_is_active(direct_pid)); + assert!(!process_is_active(descendant_pid)); + assert!(terminal_host_pids(&host_root).is_empty()); +} + +#[cfg(unix)] +#[test] +fn server_stop_kills_ephemeral_pty_process_groups() { + let mut server = HeadlessServer::start_with_shutdown_owner_diagnostic( + "server-stop-ephemeral-process-group", + true, + ); + let descendant_pid_file = server.dir.join("ephemeral-descendant.pid"); + let descendant_pid_published_path = server.dir.join("ephemeral-descendant-pid-published"); + let mut descendant_pid_published = TestFifoSignal::create(&descendant_pid_published_path); + let command = format!( + "trap '' HUP; (trap '' HUP; while :; do sleep 1; done) & echo $! > {}; printf 1 > {}; wait", + descendant_pid_file.display(), + descendant_pid_published_path.display() + ); + let surface = + raw_json(&server, serde_json::json!({"id":"ephemeral-run","cmd":"run","command":command})) + ["surface"] + .as_u64() + .unwrap(); + let info = raw_json( + &server, + serde_json::json!({"id":"ephemeral-process","cmd":"process-info","surface":surface}), + ); + let direct_pid = u32::try_from(info["pid"].as_u64().unwrap()).unwrap(); + descendant_pid_published.receive(Duration::from_secs(5)); + let descendant_pid = + fs::read_to_string(&descendant_pid_file).unwrap().trim().parse::().unwrap(); + + let stop = cli(&server, &["--json", "server", "stop"]); + let shutdown_diagnostic = server.shutdown_owner_diagnostic(); + + assert_success_with_context(&stop, &shutdown_diagnostic); + assert!(server.child.wait().unwrap().success()); + assert!(!process_is_active(direct_pid)); + assert!(!process_group_is_active(direct_pid)); + assert!(!process_is_active(descendant_pid)); +} + +#[cfg(unix)] +#[test] +fn server_stop_kills_background_jobs_in_separate_pty_process_groups() { + let mut server = HeadlessServer::start_with_shutdown_owner_diagnostic( + "server-stop-ephemeral-job-control-group", + true, + ); + let descendant_pid_file = server.dir.join("ephemeral-background-job.pid"); + let descendant_pid_published_path = server.dir.join("ephemeral-background-job-pid-published"); + let mut descendant_pid_published = TestFifoSignal::create(&descendant_pid_published_path); + let command = format!( + concat!( + "exec /bin/bash --noprofile --norc -i -c '", + "trap \"\" HUP; ", + "(trap \"\" HUP; while :; do sleep 60; done) & ", + "printf \"%s\" \"$!\" > {}; printf \"1\" > {}; wait'", + ), + descendant_pid_file.display(), + descendant_pid_published_path.display() + ); + let surface = raw_json( + &server, + serde_json::json!({"id":"job-control-run","cmd":"run","command":command}), + )["surface"] + .as_u64() + .unwrap(); + let info = raw_json( + &server, + serde_json::json!({"id":"job-control-process","cmd":"process-info","surface":surface}), + ); + let direct_pid = u32::try_from(info["pid"].as_u64().unwrap()).unwrap(); + descendant_pid_published.receive(Duration::from_secs(5)); + let descendant_pid = + fs::read_to_string(&descendant_pid_file).unwrap().trim().parse::().unwrap(); + let direct_pid_raw = libc::pid_t::try_from(direct_pid).unwrap(); + let descendant_pid_raw = libc::pid_t::try_from(descendant_pid).unwrap(); + // SAFETY: both fixture processes are live and owned by this test. + let direct_group = unsafe { libc::getpgid(direct_pid_raw) }; + // SAFETY: both fixture processes are live and owned by this test. + let descendant_group = unsafe { libc::getpgid(descendant_pid_raw) }; + // SAFETY: both fixture processes are live and owned by this test. + let direct_session = unsafe { libc::getsid(direct_pid_raw) }; + // SAFETY: both fixture processes are live and owned by this test. + let descendant_session = unsafe { libc::getsid(descendant_pid_raw) }; + + let stop = cli(&server, &["--json", "server", "stop"]); + let shutdown_diagnostic = server.shutdown_owner_diagnostic(); + + assert_success_with_context(&stop, &shutdown_diagnostic); + assert!(server.child.wait().unwrap().success()); + let descendant_remained = process_is_active(descendant_pid); + if descendant_remained { + // SAFETY: the live process and group belong to this isolated fixture. + let _ = unsafe { libc::killpg(descendant_group, libc::SIGKILL) }; + // SAFETY: the live process belongs to this isolated fixture. + let _ = unsafe { libc::kill(descendant_pid_raw, libc::SIGKILL) }; + } + + assert!(direct_group > 0); + assert!(descendant_group > 0); + assert_ne!( + descendant_group, direct_group, + "fixture background job did not enter a separate process group" + ); + assert_eq!( + descendant_session, direct_session, + "fixture background job left the owned PTY session" + ); + assert!(!process_exists(direct_pid)); + assert!( + !descendant_remained, + "server acknowledged shutdown while a background job in its PTY session remained alive" + ); +} + +#[cfg(unix)] +#[test] +fn server_stop_drains_many_hosted_panes_before_acknowledging() { + let mut server = + HeadlessServer::start_with_shutdown_owner_diagnostic("server-stop-many-panes", false); + let applied = raw_json( + &server, + serde_json::json!({ + "id":"many-panes-layout", + "cmd":"apply-layout", + "layout":{ + "type":"stack", + "panes":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16, + 17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32], + "expanded":1, + }, + }), + ); + let surfaces = applied["panes"].as_array().unwrap(); + assert_eq!(surfaces.len(), 32); + let first_surface = surfaces[0]["surface"].as_u64().unwrap(); + let info = raw_json( + &server, + serde_json::json!({ + "id":"many-panes-first-process", + "cmd":"process-info", + "surface":first_surface, + }), + ); + let first_terminal_pid = u32::try_from(info["pid"].as_u64().unwrap()).unwrap(); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&server.state, "main"); + let host_pids = terminal_host_pids(&host_root); + assert_eq!(host_pids.len(), 32); + + let stop_started = Instant::now(); + let stop = cli(&server, &["--json", "server", "stop"]); + let shutdown_diagnostic = server.shutdown_owner_diagnostic(); + + assert_success_with_context(&stop, &shutdown_diagnostic); + assert!(stop_started.elapsed() < Duration::from_secs(5)); + assert!(server.child.wait().unwrap().success()); + assert!(!host_pids.iter().copied().any(process_is_active)); + assert!(!process_is_active(first_terminal_pid)); + assert!(!process_group_is_active(first_terminal_pid)); + assert!(!server.socket.exists()); +} + +#[cfg(unix)] +#[test] +fn server_stop_falls_back_when_an_older_server_lacks_shutdown_capability() { + let mut server = LegacyServerProcess::start("legacy-server-stop", "success", None); + let descendant_pid = server.descendant_pid().unwrap(); + + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert_success(&machine_agent); - let help = String::from_utf8(machine_agent.stdout).unwrap(); - assert!(help.starts_with("cmux machine-agent - share one local cmux session")); - assert!(help.contains("Authenticate with the configured host before retrying.")); - assert!(!help.contains("cmux machine register")); - assert!(!help.contains("BatchMode")); - let version = Command::new(bin()).arg("--version").output().unwrap(); - assert_success(&version); - assert!(String::from_utf8(version.stdout).unwrap().starts_with("cmux ")); + assert_success(&output); + let status = server.child.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!(!process_is_active(descendant_pid)); } +#[cfg(unix)] +#[test] +fn server_stop_fails_closed_when_another_legacy_client_can_create_surfaces() { + let mut server = + LegacyServerProcess::start("legacy-server-concurrent-client", "concurrent-client", None); + let descendant_pid = server.descendant_pid().unwrap(); + + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("could not close pane processes before stopping the older server") + ); + assert!(server.child.try_wait().unwrap().is_none()); + assert!(process_exists(descendant_pid)); + assert!(transport::connect(&server.socket).is_ok(), "failed shutdown did not restore socket"); +} + +#[cfg(unix)] #[test] -fn ghostty_config_helper_outputs_resolved_file_defaults() { - let dir = unique_temp_dir("ghostty-config-helper-output"); - let config_home = dir.join("config"); - let ghostty_dir = config_home.join("ghostty"); - fs::create_dir_all(&ghostty_dir).unwrap(); - fs::write( - ghostty_dir.join("config"), - "foreground = #010203\n\ - background = #040506\n\ - cursor-color = #070809\n\ - cursor-style = block_hollow\n\ - cursor-style-blink = false\n\ - palette = 2=#0a0b0c\n", - ) - .unwrap(); +fn server_stop_waits_for_the_initiating_legacy_client_to_drain() { + let mut server = + LegacyServerProcess::start("legacy-server-draining-client", "draining-client", None); + let descendant_pid = server.descendant_pid().unwrap(); let output = Command::new(bin()) - .arg("__ghostty-config-defaults") - .env("HOME", dir.join("home")) - .env("CFFIXED_USER_HOME", dir.join("home")) - .env("XDG_CONFIG_HOME", &config_home) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); assert_success(&output); - let stdout = String::from_utf8(output.stdout).unwrap(); - fs::remove_dir_all(dir).unwrap(); - - assert!(stdout.contains("foreground = #010203\n"), "{stdout}"); - assert!(stdout.contains("background = #040506\n"), "{stdout}"); - assert!(stdout.contains("cursor-color = #070809\n"), "{stdout}"); - assert!(stdout.contains("cursor-style = block_hollow\n"), "{stdout}"); - assert!(stdout.contains("cursor-style-blink = false\n"), "{stdout}"); - assert!(stdout.contains("palette = 2=#0a0b0c\n"), "{stdout}"); + let status = server.child.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!(!process_is_active(descendant_pid)); } -#[cfg(target_os = "linux")] +#[cfg(unix)] #[test] -fn ghostty_config_helper_scrubs_provider_env_before_desktop_probe() { - let dir = unique_temp_dir("ghostty-config-helper-provider-env"); - let config_home = dir.join("config"); - let ghostty_dir = config_home.join("ghostty"); - let theme_dir = ghostty_dir.join("themes"); - let bin_dir = dir.join("bin"); - fs::create_dir_all(&theme_dir).unwrap(); - fs::create_dir_all(&bin_dir).unwrap(); - fs::write( - ghostty_dir.join("config"), - "window-theme = system\n\ - theme = dark:Dark Direct Probe, light:Light Direct Probe\n", - ) - .unwrap(); - fs::write(theme_dir.join("Dark Direct Probe"), "foreground = #010203\n").unwrap(); - fs::write(theme_dir.join("Light Direct Probe"), "foreground = #a0b0c0\n").unwrap(); - let gdbus = bin_dir.join("gdbus"); - fs::write( - &gdbus, - "#!/bin/sh\n\ - if [ \"${CMUX_MACHINE_PROVIDER_TOKEN+x}\" = x ] || [ \"${CMUX_PROVIDER_WORKSPACE_AUTHORITY+x}\" = x ]; then\n\ - \tprintf '(,)\\n'\n\ - \texit 0\n\ - fi\n\ - printf '(,)\\n'\n", - ) - .unwrap(); - fs::set_permissions(&gdbus, fs::Permissions::from_mode(0o700)).unwrap(); +fn server_stop_closes_exited_legacy_placeholders_without_a_pid() { + let mut server = + LegacyServerProcess::start("legacy-server-dead-placeholder", "dead-surface", None); let output = Command::new(bin()) - .arg("__ghostty-config-defaults") - .env("HOME", dir.join("home")) - .env("CFFIXED_USER_HOME", dir.join("home")) - .env("XDG_CONFIG_HOME", &config_home) - .env("PATH", &bin_dir) - .env("CMUX_MACHINE_PROVIDER_TOKEN", "direct-helper-token") - .env("CMUX_PROVIDER_WORKSPACE_AUTHORITY", "direct-helper-authority") - .env_remove("AppleInterfaceStyle") - .env_remove("GTK_THEME") + .args(["server", "stop", "--socket"]) + .arg(&server.socket) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); assert_success(&output); - let stdout = String::from_utf8(output.stdout).unwrap(); - fs::remove_dir_all(dir).unwrap(); - - assert!(stdout.contains("foreground = #010203\n"), "{stdout}"); - assert!(!stdout.contains("foreground = #a0b0c0\n"), "{stdout}"); + let status = server.child.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); } #[cfg(unix)] #[test] -fn machine_agent_argument_failures_are_stable_and_localized() { +fn server_stop_fails_closed_for_a_dead_legacy_surface_with_a_live_session() { + let mut server = + LegacyServerProcess::start("legacy-server-dead-owned", "dead-owned-surface", None); + let descendant_pid = wait_for_pid_file(&server.descendant_pid_file, Duration::from_secs(5)); + let output = Command::new(bin()) - .env("LC_ALL", "ja_JP.UTF-8") - .env("LC_MESSAGES", "ja_JP.UTF-8") - .env("LANG", "ja_JP.UTF-8") - .args(["machine-agent", "--cloud-port", "invalid"]) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("--cloud-port の値が無効です: invalid")); - assert!(!stderr.contains("machine-agent を開始または続行できませんでした")); + + assert!(!output.status.success(), "live process behind a dead surface was ignored"); + assert!(server.child.try_wait().unwrap().is_none(), "legacy server was killed on ambiguity"); + assert!(process_exists(descendant_pid), "ambiguous pane process was killed without ownership"); } #[cfg(unix)] #[test] -fn known_daemon_human_output_uses_selected_locale() { - let dir = unique_temp_dir("known-daemon-locale"); - let client = dir.join("client"); - fs::create_dir_all(&client).unwrap(); - fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); - fs::set_permissions(&client, fs::Permissions::from_mode(0o700)).unwrap(); - let state = serde_json::json!({ - "version": 1, - "daemons": { - "enrolled-fingerprint": { - "fingerprint": "enrolled-fingerprint", - "name": "enrolled-host", - "public_key": "unused", - "route_hints": ["ssh://enrolled.example"], - "auth": "enrolled", - "first_seen_at_unix": 1, - "last_used_at_unix": 2 - }, - "carrier-fingerprint": { - "fingerprint": "carrier-fingerprint", - "name": "carrier-host", - "public_key": "unused", - "route_hints": ["ssh://carrier.example"], - "auth": "carrier", - "first_seen_at_unix": 1, - "last_used_at_unix": 2 - } +fn published_host_launch_errors_reap_the_complete_pty_session() { + let mut server = HeadlessServer::start_with( + "published-host-launch-error", + false, + &[("CMUX_TUI_TEST_INVALID_HOST_READY", "1"), ("CMUX_TUI_TEST_HOST_READY_DELAY_MS", "500")], + ); + let direct_pid_file = server.dir.join("failed-launch-direct.pid"); + let descendant_pid_file = server.dir.join("failed-launch-descendant.pid"); + let command = format!( + "echo $$ > {}; trap '' HUP; (trap '' HUP; while :; do sleep 1; done) & echo $! > {}; wait", + direct_pid_file.display(), + descendant_pid_file.display() + ); + + let output = raw_cli( + &server, + serde_json::json!({ + "id":"failed-published-host", + "cmd":"run", + "new_workspace":true, + "command":command, + }), + ); + assert!(!output.status.success(), "injected invalid launch acknowledgement succeeded"); + let direct_pid = wait_for_pid_file(&direct_pid_file, Duration::from_secs(5)); + let descendant_pid = wait_for_pid_file(&descendant_pid_file, Duration::from_secs(5)); + let host_root = cmux_tui_core::terminal_host_runtime::terminal_host_root(&server.state, "main"); + let cleaned = !process_is_active(direct_pid) + && !process_group_is_active(direct_pid) + && !process_is_active(descendant_pid) + && terminal_host_pids(&host_root).is_empty(); + + let stop = cli(&server, &["--json", "server", "stop"]); + assert_success(&stop); + assert!(server.child.wait().unwrap().success()); + for pid in [direct_pid, descendant_pid] { + if let Ok(pid) = libc::pid_t::try_from(pid) { + // SAFETY: both PIDs came from this isolated test fixture. + let _ = unsafe { libc::kill(pid, libc::SIGKILL) }; } - }); - let known = client.join("known-daemons.json"); - fs::write(&known, serde_json::to_vec(&state).unwrap()).unwrap(); - fs::set_permissions(&known, fs::Permissions::from_mode(0o600)).unwrap(); + } - let localized = |arguments: &[&str]| { - Command::new(bin()) - .args(arguments) - .arg("--state-dir") - .arg(&dir) - .env("LC_ALL", "ja_JP.UTF-8") - .env("LC_MESSAGES", "ja_JP.UTF-8") - .env("LANG", "ja_JP.UTF-8") - .output() - .unwrap() - }; + assert!(cleaned, "post-publication launch error left PTY ownership unreconciled"); +} - let list = localized(&["known-daemons"]); - assert_success(&list); - let list = String::from_utf8(list.stdout).unwrap(); - assert!(list.contains("\t登録済み\n"), "{list}"); - assert!(list.contains("\t信頼済み搬送路\n"), "{list}"); - assert!(!list.contains("\tenrolled\n"), "{list}"); - assert!(!list.contains("\tcarrier\n"), "{list}"); +#[cfg(unix)] +#[test] +fn legacy_stop_retains_pty_ownership_captured_before_surface_close() { + let mut server = + LegacyServerProcess::start("legacy-server-reparent-on-close", "reparent-on-close", None); + let descendant_pid = server.receive_descendant_pid(Duration::from_secs(5)); - let forget = localized(&["known-daemons", "forget", "enrolled-fingerprint"]); - assert_success(&forget); - let forget = String::from_utf8(forget.stdout).unwrap(); - assert_eq!(forget, "デーモン enrolled-fingerprint を削除しました。\n"); + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); - let empty_dir = unique_temp_dir("known-daemon-empty-locale"); - let empty = Command::new(bin()) - .args(["known-daemons", "--state-dir"]) - .arg(&empty_dir) - .env("LC_ALL", "ja_JP.UTF-8") - .env("LC_MESSAGES", "ja_JP.UTF-8") - .env("LANG", "ja_JP.UTF-8") + assert_success(&output); + let status = server.child.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!( + !process_is_active(descendant_pid), + "surface close reparented a captured PTY job outside the final server tree" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn server_stop_handles_an_exited_unreaped_legacy_descendant() { + let mut server = LegacyServerProcess::start("legacy-server-zombie-child", "zombie-child", None); + let descendant_pid = server.descendant_pid().unwrap(); + + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert_success(&empty); - assert_eq!(String::from_utf8(empty.stdout).unwrap(), "登録済みのデーモンはありません。\n"); - fs::remove_dir_all(dir).unwrap(); - fs::remove_dir_all(empty_dir).unwrap(); + assert_success(&output); + let status = server.child.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!(!process_is_active(descendant_pid)); } +#[cfg(unix)] #[test] -fn noun_first_ratio_commands_reject_nonfinite_values_before_connecting() { - const PANE: &str = "pane_11111111111111111111111111111111"; - const SPLIT: &str = "split_22222222222222222222222222222222"; - for args in [ - ["pane", PANE, "split", "--right", "--ratio", "NaN"].as_slice(), - ["pane", PANE, "split", "ratio", "set", "--split", SPLIT, "--ratio", "NaN"].as_slice(), - ] { - let output = Command::new(bin()) - .env("LC_ALL", "ja_JP.UTF-8") - .env("LC_MESSAGES", "ja_JP.UTF-8") - .env("LANG", "ja_JP.UTF-8") - .args(args) - .output() - .unwrap(); - assert_eq!(output.status.code(), Some(2)); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.starts_with("cmux: "), "{stderr}"); - assert!(stderr.contains("--ratio must be greater than 0 and less than 1"), "{stderr}"); - assert!(!stderr.contains("cmux-tui"), "{stderr}"); - } +fn server_stop_reconciles_an_applied_legacy_close_that_returned_an_error() { + let mut server = LegacyServerProcess::start( + "legacy-server-applied-close-error", + "applied-close-error", + None, + ); + let descendant_pid = server.descendant_pid().unwrap(); + + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + assert_success(&output); + let status = server.child.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!(!process_is_active(descendant_pid)); } +#[cfg(unix)] #[test] -fn noun_first_viewport_width_rejects_invalid_values_before_connecting() { - const PANE: &str = "pane_11111111111111111111111111111111"; - for (args, expected) in [ - ( - ["pane", PANE, "split", "--right", "--viewport-width", "NaN"].as_slice(), - "--viewport-width must be from 0.1 through 1", - ), - ( - ["pane", PANE, "split", "--right", "--viewport-width", "0.09"].as_slice(), - "--viewport-width must be from 0.1 through 1", - ), - ( - ["pane", PANE, "split", "--down", "--viewport-width", "0.5"].as_slice(), - "--viewport-width requires --right", - ), - ] { - let output = Command::new(bin()).args(args).output().unwrap(); - assert_eq!(output.status.code(), Some(2)); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.starts_with("cmux: "), "{stderr}"); - assert!(stderr.contains(expected), "{stderr}"); - assert!(!stderr.contains("cmux-tui"), "{stderr}"); - } +fn server_stop_reconnects_after_an_applied_legacy_close_drops_the_control_stream() { + let mut server = LegacyServerProcess::start( + "legacy-server-applied-close-disconnect", + "applied-close-disconnect", + None, + ); + let descendant_pid = server.descendant_pid().unwrap(); + + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + assert_success(&output); + let status = server.child.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!(!process_is_active(descendant_pid)); } #[cfg(unix)] -struct PtyChild { - child: Option>, - output_drain: Option>, +#[test] +fn server_stop_completes_when_the_last_legacy_surface_exits_with_its_server() { + let mut server = + LegacyServerProcess::start("legacy-server-exit-after-close", "exit-after-close", None); + let descendant_pid = server.descendant_pid().unwrap(); + + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + assert_success(&output); + assert!(server.child.wait().unwrap().success()); + assert!( + !process_is_active(descendant_pid), + "last-surface server exit leaked its captured PTY owner" + ); } #[cfg(unix)] -struct TestTempDir(PathBuf); +#[test] +fn server_stop_refuses_when_a_legacy_close_error_leaves_the_surface_present() { + let mut server = LegacyServerProcess::start( + "legacy-server-persistent-close-error", + "persistent-close-error", + None, + ); + let descendant_pid = server.descendant_pid().unwrap(); -#[cfg(unix)] -impl TestTempDir { - fn create(name: &str) -> Self { - let path = unique_temp_dir(name); - fs::create_dir_all(&path).unwrap(); - Self(path) - } + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); - fn path(&self) -> &std::path::Path { - &self.0 - } + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("could not close pane processes before stopping the older server") + ); + assert!(server.child.try_wait().unwrap().is_none()); + assert!(process_exists(descendant_pid)); + assert!(transport::connect(&server.socket).is_ok(), "failed shutdown did not restore socket"); } #[cfg(unix)] -impl Drop for TestTempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } +#[test] +fn detached_legacy_stop_survives_the_calling_pane_process_exit() { + let mut server = LegacyServerProcess::start("legacy-server-detached", "kill-caller", None); + let descendant_pid = server.descendant_pid().unwrap(); + + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + assert_eq!(output.status.signal(), Some(libc::SIGKILL)); + let status = server.child.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!(!process_is_active(descendant_pid)); } #[cfg(unix)] -impl PtyChild { - fn start(args: &[&str]) -> Self { - Self::start_with_env(args, &[]) - } +#[test] +fn server_stop_does_not_force_kill_a_compatible_server_after_shutdown_failure() { + let mut server = + LegacyServerProcess::start("server-shutdown-failure", "compatible-failure", None); - fn start_with_env(args: &[&str], env: &[(&str, &std::ffi::OsStr)]) -> Self { - let spawned = spawn_pty_child(args, env); - let mut master = spawned.master.try_clone_reader().unwrap(); - let output_drain = std::thread::spawn(move || { - let mut buffer = [0; 8192]; - while master.read(&mut buffer).is_ok_and(|read| read > 0) {} - }); - Self { child: Some(spawned.child), output_drain: Some(output_drain) } - } + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); - fn wait_for_exit(&mut self, timeout: Duration) -> Option { - let mut child = self.child.take().expect("PTY child already has an exit waiter"); - let mut killer = child.clone_killer(); - let (sender, receiver) = mpsc::sync_channel(1); - let _waiter = std::thread::spawn(move || { - let _ = sender.send(child.wait()); - }); - match receiver.recv_timeout(timeout) { - Ok(status) => Some(status.unwrap()), - Err(mpsc::RecvTimeoutError::Timeout) => { - let _ = killer.kill(); - match receiver.recv_timeout(Duration::from_secs(5)) { - Ok(Ok(_)) => {} - Ok(Err(error)) => { - panic!("interactive owner did not exit cleanly after kill: {error}"); - } - Err(mpsc::RecvTimeoutError::Timeout) => { - let _ = self.output_drain.take(); - panic!("interactive owner did not exit after kill"); - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - let _ = self.output_drain.take(); - panic!("interactive owner exit waiter disconnected after kill"); - } - } - None - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - let _ = self.output_drain.take(); - panic!("interactive owner exit waiter disconnected") - } - } - } + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("could not stop cleanly")); + assert!(server.child.try_wait().unwrap().is_none()); } #[cfg(unix)] -impl Drop for PtyChild { - fn drop(&mut self) { - if let Some(child) = self.child.as_mut() { - let _ = child.kill(); - let _ = child.wait(); - } - if let Some(output_drain) = self.output_drain.take() { - let _ = output_drain.join(); - } - } -} +#[test] +fn server_stop_does_not_force_kill_a_mismatched_modern_server_after_shutdown_failure() { + let mut server = LegacyServerProcess::start( + "mismatched-server-shutdown-failure", + "mismatched-modern-failure", + None, + ); -#[cfg(unix)] -struct DisconnectablePtyChild { - child: Box, - master: Option>, + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("could not stop cleanly")); + assert!(server.child.try_wait().unwrap().is_none()); } #[cfg(unix)] -impl DisconnectablePtyChild { - fn start(args: &[&str]) -> Self { - let spawned = spawn_pty_child(args, &[]); - Self { child: spawned.child, master: Some(spawned.master) } - } +#[test] +fn server_stop_does_not_signal_an_older_server_when_pane_cleanup_fails() { + let mut server = + LegacyServerProcess::start("legacy-server-cleanup-failure", "cleanup-failure", None); + let descendant_pid = server.descendant_pid().unwrap(); - fn disconnect_host_terminal(&mut self) { - self.master.take(); - } -} + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); -#[cfg(unix)] -fn spawn_pty_child(args: &[&str], env: &[(&str, &std::ffi::OsStr)]) -> cmux_pty::SpawnedPty { - let pair = - cmux_pty::open(cmux_pty::PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 }) - .unwrap(); - let mut command = cmux_pty::PtyCommand::new(bin()); - command.args(args.iter().copied()); - command.env_clear(); - for (key, value) in std::env::vars() { - if key != "CMUX_TUI_SOCKET" { - command.env(key, value); - } - } - for (key, value) in env { - command.env(*key, value.to_string_lossy()); - } - pair.spawn(command).unwrap() + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("could not close pane processes before stopping the older server") + ); + assert!(server.child.try_wait().unwrap().is_none()); + assert!(process_exists(descendant_pid)); } #[cfg(unix)] -fn plain_tui_is_ready(server: &HeadlessServer) -> bool { - let clients = json_cli(server, &["client", "list"]); - if !clients.status.success() - || !json_output(&clients).as_array().is_some_and(|clients| { - clients.iter().any(|client| client["client_kind"].as_str() == Some("tui")) - }) - { - return false; - } +#[test] +fn server_stop_refuses_an_older_browser_without_target_confirmation() { + let mut server = LegacyServerProcess::start("legacy-server-browser", "browser-surface", None); - let terminals = json_cli(server, &["terminal", "list"]); - terminals.status.success() - && json_output(&terminals).as_array().is_some_and(|terminals| !terminals.is_empty()) -} + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); -#[cfg(unix)] -impl Drop for DisconnectablePtyChild { - fn drop(&mut self) { - self.master.take(); - let _ = self.child.kill(); - let _ = self.child.wait(); - } + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("could not close pane processes before stopping the older server") + ); + assert!(server.child.try_wait().unwrap().is_none()); } #[cfg(unix)] #[test] -fn startup_does_not_invoke_external_ghostty_config_resolver() { - let dir = unique_temp_dir("external-ghostty-config-resolver"); - fs::create_dir_all(&dir).unwrap(); - let helper = dir.join("ghostty-config-probe"); - let capture = dir.join("resolver-invoked.txt"); - let socket = dir.join("mux.sock"); - fs::write( - &helper, - r#"#!/bin/sh -echo invoked > "$CMUX_TEST_GHOSTTY_CAPTURE" -exit 0 -"#, - ) - .unwrap(); - fs::set_permissions(&helper, fs::Permissions::from_mode(0o700)).unwrap(); +fn server_stop_refuses_a_legacy_server_that_spoofs_another_process_id() { + let mut victim = + Command::new("yes").stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + let mut server = + LegacyServerProcess::start("legacy-server-spoofed-pid", "spoofed-pid", Some(victim.id())); let output = Command::new(bin()) - .args(["--machine-provider", "/does/not/exist", "--headless", "--socket"]) - .arg(&socket) - .env("GHOSTTY_BIN", &helper) - .env("CMUX_TEST_GHOSTTY_CAPTURE", &capture) - .env("CMUX_MACHINE_PROVIDER_TOKEN", "edge-test-bearer") - .env("CMUX_PROVIDER_WORKSPACE_AUTHORITY", "provider-workspace-authority-test-00000001") + .args(["server", "stop", "--socket"]) + .arg(&server.socket) .env_remove("CMUX_TUI_SOCKET") .output() .unwrap(); - assert!(!output.status.success(), "conflicting provider launch unexpectedly succeeded"); - assert!(!capture.exists(), "startup invoked external Ghostty config resolver"); - fs::remove_dir_all(dir).unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("reported process does not own its socket") + ); + assert!(victim.try_wait().unwrap().is_none()); + assert!(server.child.try_wait().unwrap().is_none()); + victim.kill().unwrap(); + victim.wait().unwrap(); } #[cfg(unix)] #[test] -fn plain_launch_attaches_to_existing_local_session() { - let server = HeadlessServer::start("plain-launch-attach"); - let mut tui = PtyChild::start(&["--socket", server.socket.to_str().unwrap()]); - let deadline = Instant::now() + Duration::from_secs(10); +fn server_stop_never_signals_an_unowned_legacy_surface_pid() { + let mut command = Command::new("yes"); + command.stdout(Stdio::null()).stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut victim = command.spawn().unwrap(); + let mut server = LegacyServerProcess::start_with_surface_pid( + "legacy-server-unowned-surface", + "unowned-surface", + None, + Some(victim.id()), + ); - while Instant::now() < deadline { - if let Some(status) = tui.child.as_mut().unwrap().try_wait().unwrap() { - panic!("plain launch exited instead of attaching: {status}"); - } - if plain_tui_is_ready(&server) { - return; - } - std::thread::sleep(Duration::from_millis(50)); + let output = Command::new(bin()) + .args(["server", "stop", "--socket"]) + .arg(&server.socket) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + + let victim_survived = victim.try_wait().unwrap().is_none(); + let server_survived = server.child.try_wait().unwrap().is_none(); + if victim_survived { + victim.kill().unwrap(); + victim.wait().unwrap(); } - panic!("plain launch never attached to its committed initial terminal"); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("could not close pane processes before stopping the older server") + ); + assert!(victim_survived, "legacy cleanup signaled a process outside the server tree"); + assert!(server_survived, "legacy cleanup stopped the server after ownership proof failed"); } #[cfg(unix)] @@ -2917,6 +5001,9 @@ fn explicit_attach_registers_a_full_session_tui_client() { } assert!(attached.iter().any(|id| id.as_str() == Some(terminal.as_str()))); assert!(attached.iter().any(|id| id.as_str() == Some(second_terminal.as_str()))); + let client_id = client["id"].as_str().expect("TUI client public id").to_string(); + let detached = cli(&server, &["--quiet", "client", client_id.as_str(), "detach"]); + assert_success(&detached); return; } } @@ -2963,7 +5050,11 @@ fn graceful_shutdown_stops_server_owned_sidebar_process() { let server_pid = libc::pid_t::try_from(server.child.id()).unwrap(); // SAFETY: this PID is the live child owned by the test fixture. assert_eq!(unsafe { libc::kill(server_pid, libc::SIGINT) }, 0); - let server_stopped = wait_for_child_exit(&mut server.child, Duration::from_secs(10)); + let server_status = wait_for_child_exit( + &mut server.child, + Duration::from_secs(10), + "SIGINT did not complete graceful server shutdown", + ); let owned_processes_stopped = wait_for_processes_to_exit(&owned_pids, Duration::from_secs(5)); // Keep lifecycle regressions leak-free. Every captured process group and @@ -2989,7 +5080,7 @@ fn graceful_shutdown_stops_server_owned_sidebar_process() { } } - assert!(server_stopped, "SIGINT did not complete graceful server shutdown"); + assert!(server_status.success(), "SIGINT server shutdown exited with {server_status}"); assert!( !used_durable_host, "server-owned sidebar process entered the durable terminal-host registry" @@ -3010,17 +5101,10 @@ fn configured_websocket_server_does_not_attach_to_existing_session() { &["--socket", server.socket.to_str().unwrap()], &[("CMUX_TUI_CONFIG", config.as_os_str())], ); - let deadline = Instant::now() + Duration::from_secs(10); - - while Instant::now() < deadline { - if let Some(status) = tui.child.as_mut().unwrap().try_wait().unwrap() { - assert!(!status.success(), "server launch unexpectedly succeeded"); - return; - } - std::thread::sleep(Duration::from_millis(50)); - } - - panic!("configured WebSocket server attached instead of preserving server mode"); + let status = tui + .wait_for_exit(Duration::from_secs(10)) + .expect("configured WebSocket server attached instead of preserving server mode"); + assert!(!status.success(), "server launch unexpectedly succeeded"); } #[cfg(unix)] @@ -3850,14 +5934,18 @@ fn json_cli(server: &HeadlessServer, args: &[&str]) -> Output { } fn raw_cli(server: &HeadlessServer, request: serde_json::Value) -> Output { + raw_cli_command(&server.socket, request).output().unwrap() +} + +fn raw_cli_command(socket: &std::path::Path, request: serde_json::Value) -> Command { let request = serde_json::to_string(&request).unwrap(); - Command::new(bin()) + let mut command = Command::new(bin()); + command .args(["--json", "--socket"]) - .arg(&server.socket) + .arg(socket) .args(["raw", "command", "--request-json", &request]) - .env_remove("CMUX_TUI_SOCKET") - .output() - .unwrap() + .env_remove("CMUX_TUI_SOCKET"); + command } fn raw_json(server: &HeadlessServer, request: serde_json::Value) -> serde_json::Value { @@ -3933,6 +6021,18 @@ fn assert_success(output: &Output) { ); } +#[track_caller] +fn assert_success_with_context(output: &Output, context: &str) { + assert!( + output.status.success(), + "expected success, got status {:?}\nstdout:\n{}\nstderr:\n{}\ncontext:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + context, + ); +} + fn find_session_database(state: &std::path::Path, session: &str) -> PathBuf { fs::read_dir(state) .unwrap() @@ -3953,7 +6053,7 @@ fn find_session_database(state: &std::path::Path, session: &str) -> PathBuf { } #[cfg(unix)] -fn create_live_terminal_host_record(root: &std::path::Path) -> fs::File { +fn create_live_terminal_host_record(root: &std::path::Path) -> File { fs::create_dir_all(root).unwrap(); fs::set_permissions(root, fs::Permissions::from_mode(0o700)).unwrap(); let terminal_id = "0000000000004000800000000000002a"; @@ -3971,6 +6071,7 @@ fn create_live_terminal_host_record(root: &std::path::Path) -> fs::File { host_start_nonce: host_start_nonce.clone(), workspace_key: String::new(), supports_set_defaults: true, + supports_terminate_only: false, supports_clear_history: true, supports_terminate_ack: false, }; diff --git a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs index f0ee25b04eb5..9f9e78bf1f4d 100644 --- a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs +++ b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs @@ -1,14 +1,14 @@ #![cfg(unix)] -use std::fs; -use std::io::{BufRead, BufReader, Write}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Read, Write}; use std::os::fd::AsRawFd; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, mpsc}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::Engine; @@ -17,8 +17,9 @@ use cmux_tui_core::terminal_host::{ CAPABILITY_TOKEN_LEN, CapabilityRights, CapabilityToken, ClientHello, ClientRole, TerminalId, }; use cmux_tui_core::terminal_host_protocol::{ - FLAG_COLORS_FOLLOW, FLAG_VIEWER_SIZE_ACKS, Frame, MAX_FRAME_PAYLOAD, MessageKind, - PROTOCOL_VERSION, ProtocolError, RESIZE_ACK_CANONICAL_CHANGED, read_frame, write_frame, + FLAG_COLORS_FOLLOW, FLAG_TERMINATE_ONLY, FLAG_VIEWER_SIZE_ACKS, Frame, MAX_FRAME_PAYLOAD, + MessageKind, PROTOCOL_VERSION, ProtocolError, RESIZE_ACK_CANONICAL_CHANGED, read_frame, + write_frame, }; use cmux_tui_core::terminal_host_runtime::{ TerminalHostLiveness, TerminalHostRecord, acknowledge_terminal_host_exit_record, @@ -28,6 +29,40 @@ use cmux_tui_core::terminal_host_runtime::{ }; use ghostty_vt::{Rgb, TerminalColorOverrides}; +struct TestFifoSignal(File); + +impl TestFifoSignal { + fn create(path: &Path) -> Self { + let path_name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: path_name is a valid, NUL-terminated path in the test directory. + assert_eq!(unsafe { libc::mkfifo(path_name.as_ptr(), 0o600) }, 0); + Self(fs::OpenOptions::new().read(true).write(true).open(path).unwrap()) + } + + fn receive(&mut self, timeout: Duration) { + let timeout = timeout.as_millis().clamp(1, i32::MAX as u128) as i32; + let mut descriptor = + libc::pollfd { fd: self.0.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: descriptor points to one initialized pollfd for this call. + assert_eq!(unsafe { libc::poll(&raw mut descriptor, 1, timeout) }, 1); + assert_ne!(descriptor.revents & libc::POLLIN, 0); + let mut byte = [0_u8; 1]; + self.0.read_exact(&mut byte).unwrap(); + } +} + +fn test_timeout_scale() -> u32 { + std::env::var("CMUX_TEST_TIMEOUT_SCALE") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|scale| *scale > 0) + .unwrap_or(1) +} + +fn scaled_timeout(timeout: Duration) -> Duration { + timeout.saturating_mul(test_timeout_scale()) +} + const KITTY_REPLAY_STATE_ENCODED_LEN: usize = 52; fn test_timeout(timeout: Duration) -> Duration { @@ -133,6 +168,23 @@ impl RecoveryHarness { wait_for_socket(&self.socket); } + fn restart_with_adoption_signal(&mut self, signal: &Path) { + assert!(self.child.is_none()); + let mut command = self.daemon_command(); + command.env("CMUX_TUI_TEST_TERMINAL_ADOPTED_SIGNAL", signal); + self.child = Some(command.spawn().unwrap()); + wait_for_socket(&self.socket); + } + + fn spawn_with_adoption_retry_signals(&mut self, retry: &Path, adopted: &Path) { + assert!(self.child.is_none()); + let mut command = self.daemon_command(); + command + .env("CMUX_TUI_TEST_TERMINAL_ADOPTION_RETRY_SIGNAL", retry) + .env("CMUX_TUI_TEST_TERMINAL_ADOPTED_SIGNAL", adopted); + self.child = Some(command.spawn().unwrap()); + } + fn daemon_command(&self) -> Command { let mut command = Command::new(bin()); command @@ -774,25 +826,104 @@ fn explicit_terminate_reaps_descendants_in_the_pty_group() { } #[test] -fn exit_follows_all_final_pty_bytes_on_the_live_stream() { - let harness = RecoveryHarness::start("exit-after-final-bytes"); - request( +fn explicit_terminate_reaps_background_jobs_in_separate_pty_process_groups() { + let harness = RecoveryHarness::start("terminate-job-control-group"); + let marker = format!("job-control-background-ready-{}", std::process::id()); + let descendant_pid_path = harness.dir.join("job-control-background.pid"); + let created = request( &harness.socket, serde_json::json!({ "id": 1, "cmd": "run", "argv": [ - "/bin/sh", + "/bin/bash", + "--noprofile", + "--norc", + "-i", "-c", concat!( - "IFS= read -r trigger; i=0; ", - "while [ \"$i\" -lt 20000 ]; do ", - "printf 'drain-%05d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; ", - "i=$((i + 1)); done; ", - "printf 'FINAL-PTY-BYTE-MARKER\\n'", + "trap '' HUP; ", + "(trap '' HUP; while :; do sleep 60; done) & ", + "printf '%s' \"$!\" > \"$1\"; printf '%s\\n' \"$2\"; wait", ), + "cmux-job-control-background", + descendant_pid_path, + marker, ], "new_workspace": true, + "name": "job-control-background", + }), + ); + let surface = created["surface"].as_u64().unwrap(); + assert!(wait_for_screen(&harness.socket, surface, &marker).contains(&marker)); + let descendant_pid = wait_for_pid_file(&harness.dir.join("job-control-background.pid")); + let (record_path, record) = wait_for_host_records(&harness.host_root(), 1).remove(0); + let observer = adopt_terminal_host(record.clone(), record_path.clone()).unwrap(); + let direct_pid = observer.snapshot.pid.unwrap() as libc::pid_t; + observer.disconnect(); + assert!(process_exists(direct_pid), "direct PTY child exited before Terminate"); + assert!(process_exists(descendant_pid), "background job exited before Terminate"); + // SAFETY: both fixture processes are live and owned by this test. + let direct_group = unsafe { libc::getpgid(direct_pid) }; + // SAFETY: both fixture processes are live and owned by this test. + let descendant_group = unsafe { libc::getpgid(descendant_pid) }; + // SAFETY: both fixture processes are live and owned by this test. + let direct_session = unsafe { libc::getsid(direct_pid) }; + // SAFETY: both fixture processes are live and owned by this test. + let descendant_session = unsafe { libc::getsid(descendant_pid) }; + + let mut host = adopt_terminal_host(record, record_path).unwrap(); + host.terminate().unwrap(); + host.disconnect(); + wait_for_no_host_records(&harness.host_root()); + let descendant_remained = process_is_active(descendant_pid); + if descendant_remained { + // SAFETY: the live process and group belong to this isolated fixture. + let _ = unsafe { libc::killpg(descendant_group, libc::SIGKILL) }; + // SAFETY: the live process belongs to this isolated fixture. + let _ = unsafe { libc::kill(descendant_pid, libc::SIGKILL) }; + } + + assert!(direct_group > 0); + assert!(descendant_group > 0); + assert_ne!( + descendant_group, direct_group, + "fixture background job did not enter a separate process group" + ); + assert_eq!( + descendant_session, direct_session, + "fixture background job left the owned PTY session" + ); + wait_for_process_and_group_absent(direct_pid); + assert!( + !descendant_remained, + "terminal host exited after killing only the shell and foreground process groups" + ); +} + +#[test] +fn exit_follows_all_final_pty_bytes_on_the_live_stream() { + let harness = RecoveryHarness::start("exit-after-final-bytes"); + let timeout_scale = test_timeout_scale(); + // Keep enough bytes in flight for the PTY reader to finish after the child, + // but emit them in large writes so this exit-ordering test cannot trip the + // independent 256-frame slow-client guard under scheduler contention. + let drain_chunks = 16_u32.div_ceil(timeout_scale.saturating_mul(timeout_scale)); + let script = format!( + concat!( + "IFS= read -r trigger; ", + "dd if=/dev/zero bs=65536 count={} 2>/dev/null; ", + "printf 'FINAL-PTY-BYTE-MARKER\\n'", + ), + drain_chunks, + ); + request( + &harness.socket, + serde_json::json!({ + "id": 1, + "cmd": "run", + "argv": ["/bin/sh", "-c", script], + "new_workspace": true, "name": "final-byte-ordering", }), ); @@ -805,7 +936,7 @@ fn exit_follows_all_final_pty_bytes_on_the_live_stream() { CapabilityRights::ADMIN, ) .unwrap(); - renderer.stream.set_read_timeout(Some(Duration::from_secs(15))).unwrap(); + renderer.stream.set_read_timeout(Some(scaled_timeout(Duration::from_secs(15)))).unwrap(); write_frame(&mut renderer.stream, &Frame::new(MessageKind::Input, b"go\n".to_vec())).unwrap(); let mut output = Vec::new(); @@ -1444,6 +1575,53 @@ fn mint_capability_fences_prior_admin_input_before_renderer_input() { wait_for_no_host_records(&harness.host_root()); } +#[test] +fn terminate_only_owner_handshake_never_materializes_a_snapshot() { + let harness = RecoveryHarness::start("terminate-only"); + request( + &harness.socket, + serde_json::json!({ + "id": 1, + "cmd": "run", + "argv": ["/bin/cat"], + "new_workspace": true, + }), + ); + let (_, record) = wait_for_host_records(&harness.host_root(), 1).remove(0); + assert!(record.supports_terminate_only); + let mut stream = UnixStream::connect(&record.endpoint).unwrap(); + let hello = ClientHello { + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, + role: ClientRole::Admin, + requested_rights: CapabilityRights::TERMINATE, + terminal_id: TerminalId::from_bytes(decode_hex(&record.terminal_id).unwrap()), + token: CapabilityToken::from_bytes(decode_hex(&record.owner_token).unwrap()), + }; + let mut hello = hello.into_frame(1); + hello.flags = FLAG_TERMINATE_ONLY; + write_frame(&mut stream, &hello).unwrap(); + let response = read_frame(&mut stream, MAX_FRAME_PAYLOAD).unwrap().unwrap(); + assert_eq!(response.kind, MessageKind::HostHello); + assert_eq!(response.flags, FLAG_TERMINATE_ONLY); + + write_frame(&mut stream, &Frame::new(MessageKind::Terminate, Vec::new())).unwrap(); + stream.set_read_timeout(Some(Duration::from_millis(250))).unwrap(); + match read_frame(&mut stream, MAX_FRAME_PAYLOAD) { + Ok(None) => {} + Err(ProtocolError::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::ConnectionReset + ) => {} + Ok(Some(frame)) => panic!("terminate-only handshake emitted {:?}", frame.kind), + Err(error) => panic!("unexpected terminate-only close error: {error}"), + } + wait_for_no_host_records(&harness.host_root()); +} + #[test] fn terminal_host_survives_sigkill_and_is_adopted_with_io_and_size() { let mut harness = RecoveryHarness::start("sigkill-adopt"); @@ -1648,7 +1826,16 @@ fn terminal_host_survives_sigkill_and_is_adopted_with_io_and_size() { assert!(endpoint.exists(), "terminal host socket disappeared with the daemon"); assert_eq!(wait_for_host_records(&harness.host_root(), 1)[0].1.terminal_id, terminal_id); - harness.restart(); + let adoption_signal_path = harness.dir.join("terminal-adopted"); + let mut adoption_signal = TestFifoSignal::create(&adoption_signal_path); + harness.restart_with_adoption_signal(&adoption_signal_path); + adoption_signal.receive(Duration::from_secs(15)); + let adopted_surface = adopted_terminal( + &harness.socket, + &terminal_id, + &incarnation, + "terminal host was not adopted", + ); let recovered = request(&harness.socket, serde_json::json!({"id": 4, "cmd": "list-workspaces"})); let recovered_workspace = recovered["workspaces"] @@ -1657,8 +1844,7 @@ fn terminal_host_survives_sigkill_and_is_adopted_with_io_and_size() { .iter() .find(|item| item["key"].as_str() == Some(&workspace_key)) .expect("durable workspace was not recovered"); - let adopted_surface = - first_surface(recovered_workspace).expect("terminal host was not adopted"); + assert_eq!(first_surface(recovered_workspace), Some(adopted_surface)); let state = wait_for_cursor_visual(&harness.socket, adopted_surface, "block", true); assert_eq!(state["colors"]["cursor_style"], "block"); assert_eq!(state["colors"]["cursor_blink"], true); @@ -1701,7 +1887,16 @@ fn terminal_host_survives_sigkill_and_is_adopted_with_io_and_size() { // A second crash proves the resize landed in the PTY-owning process, not // only in the disposable daemon-side Ghostty mirror. harness.sigkill(); - harness.restart(); + let adoption_signal_path = harness.dir.join("resized-terminal-adopted"); + let mut adoption_signal = TestFifoSignal::create(&adoption_signal_path); + harness.restart_with_adoption_signal(&adoption_signal_path); + adoption_signal.receive(Duration::from_secs(15)); + let resized_surface = adopted_terminal( + &harness.socket, + &terminal_id, + &incarnation, + "resized terminal host was not adopted", + ); let recovered = request(&harness.socket, serde_json::json!({"id": 8, "cmd": "list-workspaces"})); let recovered_workspace = recovered["workspaces"] @@ -1710,8 +1905,7 @@ fn terminal_host_survives_sigkill_and_is_adopted_with_io_and_size() { .iter() .find(|item| item["key"].as_str() == Some(&workspace_key)) .expect("workspace was not recovered after the resized host survived again"); - let resized_surface = - first_surface(recovered_workspace).expect("resized terminal host was not adopted"); + assert_eq!(first_surface(recovered_workspace), Some(resized_surface)); let state = request( &harness.socket, serde_json::json!({"id": 9, "cmd": "vt-state", "surface": resized_surface}), @@ -1997,10 +2191,34 @@ fn client_reserved_short_lived_create_replays_its_durable_exit_without_topology( for field in ["surface", "pane", "screen", "workspace"] { assert_eq!(first[field].is_null(), already_exited, "unexpected {field}: {first}"); } + let snapshot = resource_request( + &harness.socket, + "reserved-short-lived-create-snapshot", + "session.snapshot", + serde_json::json!({"machine":"current","session":"current"}), + None, + ); + let terminals = snapshot["terminals"].as_array().expect("snapshot terminals"); + assert_eq!(terminals.len(), 1, "unexpected terminal snapshot: {snapshot}"); + let public_terminal_id = terminals[0]["id"].as_str().expect("durable public terminal id"); if already_exited { assert_eq!(first["exit"]["outcome"], serde_json::json!({"kind":"exit","code":17})); } else { assert!(first["exit"].is_null()); + let waited = resource_request( + &harness.socket, + "reserved-short-lived-create-wait", + "terminal.wait_exit", + serde_json::json!({ + "machine":"current", + "session":"current", + "terminal":public_terminal_id, + "timeout_ms":"5000", + }), + None, + ); + assert_eq!(waited["state"], "exited", "terminal did not exit: {waited}"); + assert_eq!(waited["outcome"], serde_json::json!({"kind":"exit","code":17})); } wait_for_no_host_records(&harness.host_root()); @@ -2040,7 +2258,7 @@ fn stalled_renderer_is_disconnected_without_freezing_the_host() { "ttl_ms": 10_000, }), ); - let stalled = connect_host_detailed( + let mut stalled = connect_host_detailed( grant["endpoint"].as_str().unwrap(), grant["terminal_id"].as_str().unwrap(), grant["token"].as_str().unwrap(), @@ -2049,30 +2267,28 @@ fn stalled_renderer_is_disconnected_without_freezing_the_host() { ) .unwrap(); + let ready = format!("overflow-ready-{}", std::process::id()); request( &harness.socket, serde_json::json!({ "id": 3, "cmd": "send", "surface": surface, - // A finite burst can fit in Darwin's dynamically sized socket - // buffers under some scheduler interleavings. One streaming - // process fills the bounded host tap without per-megabyte process - // launches competing with the parallel recovery suite. - "text": "/bin/cat /dev/zero\n", + "text": format!( + // Keep the producer active until the bounded host queue closes + // this renderer. A finite burst can fit entirely in a + // dynamically sized local-socket buffer on either platform. + "printf '{ready}\\n'; \ + /usr/bin/head -c 1000000000000 /dev/zero & flood=$!\n" + ), }), ); + assert!(wait_for_screen(&harness.socket, surface, &ready).contains(&ready)); assert!( - wait_for_socket_hangup(&stalled.stream, Duration::from_secs(15)), + wait_for_stream_disconnect(&mut stalled.stream, scaled_timeout(Duration::from_secs(15))), "stalled renderer silently froze instead of being disconnected" ); - request( - &harness.socket, - serde_json::json!({"id": 31, "cmd": "send", "surface": surface, "text": "\u{3}"}), - ); - std::thread::sleep(Duration::from_millis(50)); - // Overflow is isolated to the stalled renderer. The daemon proxy and PTY // remain responsive and the durable host record remains adoptable. let after = format!("host-still-live-{}", std::process::id()); @@ -2082,7 +2298,10 @@ fn stalled_renderer_is_disconnected_without_freezing_the_host() { "id": 4, "cmd": "send", "surface": surface, - "text": format!("printf '{after}\\n'\n"), + "text": format!( + "kill \"$flood\" 2>/dev/null; wait \"$flood\" 2>/dev/null; \ + printf '{after}\\n'\n" + ), }), ); assert!(wait_for_screen(&harness.socket, surface, &after).contains(&after)); @@ -2125,11 +2344,10 @@ fn daemon_admin_backpressure_reconnects_without_restarting_host_or_renderer() { .unwrap(); let mut renderer_writer = renderer.stream.try_clone().unwrap(); let drained = Arc::new(AtomicUsize::new(0)); + let (drain_progress_tx, drain_progress_rx) = std::sync::mpsc::channel(); let drain_count = drained.clone(); - let (overflow_tx, overflow_rx) = mpsc::sync_channel(1); let drain = std::thread::spawn(move || { let mut renderer = renderer; - let mut reported = false; loop { match read_frame(&mut renderer.stream, MAX_FRAME_PAYLOAD) { Ok(Some(frame)) => { @@ -2138,12 +2356,9 @@ fn daemon_admin_backpressure_reconnects_without_restarting_host_or_renderer() { renderer.next_sequence = renderer.next_sequence.wrapping_add(1); } if frame.kind == MessageKind::Output { - let total = drain_count.fetch_add(frame.payload.len(), Ordering::AcqRel) + let drained = drain_count.fetch_add(frame.payload.len(), Ordering::AcqRel) + frame.payload.len(); - if total >= 12_000_000 && !reported { - reported = true; - let _ = overflow_tx.send(()); - } + drain_progress_tx.send(drained).unwrap(); } } Ok(None) | Err(ProtocolError::Truncated { .. }) | Err(ProtocolError::Io(_)) => { @@ -2158,15 +2373,25 @@ fn daemon_admin_backpressure_reconnects_without_restarting_host_or_renderer() { // enough PTY output fills the daemon tap's bounded queue and deliberately // disconnects that admin stream. harness.signal_daemon(libc::SIGSTOP); - write_frame( - &mut renderer_writer, - &Frame::new( - MessageKind::Input, - b"/usr/bin/head -c 14000000 /dev/zero; printf '\\nadmin-flood-done\\n'\n".to_vec(), - ), - ) - .unwrap(); - overflow_rx.recv_timeout(Duration::from_secs(15)).unwrap(); + for chunk in 1..=14 { + write_frame( + &mut renderer_writer, + &Frame::new(MessageKind::Input, b"/usr/bin/head -c 1000000 /dev/zero\n".to_vec()), + ) + .unwrap(); + let expected = chunk * 1_000_000; + let deadline = Instant::now() + scaled_timeout(Duration::from_secs(15)); + while drained.load(Ordering::Acquire) < expected { + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("renderer did not drain output before its deadline"); + drain_progress_rx.recv_timeout(remaining).unwrap(); + } + assert!( + drained.load(Ordering::Acquire) >= expected, + "renderer did not drain output chunk {chunk} while the daemon was frozen" + ); + } harness.signal_daemon(libc::SIGCONT); let after = format!("renderer-after-reconnect-{}", std::process::id()); @@ -2718,7 +2943,7 @@ fn daemon_crash_after_record_before_ready_adopts_same_live_host() { } #[test] -fn interrupted_creation_waits_for_transient_host_adoption_before_serving() { +fn interrupted_creation_retries_transient_host_adoption_before_running() { let mut harness = RecoveryHarness::start_with_host_ready_delay("pre-ready-retry", 2_000); let stream = transport::connect(&harness.socket).unwrap(); let mut writer = stream.try_clone_box().unwrap(); @@ -2741,22 +2966,28 @@ fn interrupted_creation_waits_for_transient_host_adoption_before_serving() { drop(writer); drop(stream); fs::rename(&endpoint, &held_endpoint).unwrap(); - let restore_endpoint = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(500)); - fs::rename(held_endpoint, endpoint).unwrap(); - }); - let started = Instant::now(); - harness.restart(); - restore_endpoint.join().unwrap(); - assert!( - started.elapsed() >= Duration::from_millis(400), - "daemon served before interrupted host adoption settled" - ); + let retry_signal_path = harness.dir.join("terminal-adoption-retry"); + let adopted_signal_path = harness.dir.join("terminal-adopted-after-retry"); + let mut retry_signal = TestFifoSignal::create(&retry_signal_path); + let mut adopted_signal = TestFifoSignal::create(&adopted_signal_path); + harness.spawn_with_adoption_retry_signals(&retry_signal_path, &adopted_signal_path); + retry_signal.receive(Duration::from_secs(15)); + fs::rename(held_endpoint, endpoint).unwrap(); + let adoption_deadline = Instant::now() + Duration::from_secs(15); + adopted_signal.receive(adoption_deadline.saturating_duration_since(Instant::now())); + wait_for_socket(&harness.socket); - let resolved = request( + let mut resolved = request( &harness.socket, serde_json::json!({"id":2,"cmd":"resolve-terminal","terminal_id":terminal_id}), ); + if resolved["lifecycle"] == "adopting" { + adopted_signal.receive(adoption_deadline.saturating_duration_since(Instant::now())); + resolved = request( + &harness.socket, + serde_json::json!({"id":2,"cmd":"resolve-terminal","terminal_id":terminal_id}), + ); + } let surface = resolved["surface"].as_u64().expect("recovered terminal has a surface"); assert_eq!(resolved["lifecycle"], "running"); assert_eq!(resolved["terminal_incarnation"], incarnation); @@ -2819,6 +3050,7 @@ fn interrupted_public_creation_publishes_once_and_replays_stable_ids_after_two_r assert_eq!(first_snapshot["cursor"]["revision"], "1"); assert_eq!(first_snapshot["workspaces"].as_array().unwrap().len(), 1); assert_eq!(first_snapshot["terminals"].as_array().unwrap().len(), 1); + assert_eq!(first_snapshot["terminals"][0]["id"], created_path["terminal_id"]); let after_first_restart = wait_for_host_records(&harness.host_root(), 1).remove(0).1; assert_eq!(after_first_restart.host_pid, before.host_pid); assert_eq!(after_first_restart.host_start_nonce, before.host_start_nonce); @@ -2865,6 +3097,7 @@ fn interrupted_public_creation_publishes_once_and_replays_stable_ids_after_two_r assert_eq!(second_snapshot["cursor"]["revision"], "1"); assert_eq!(second_snapshot["workspaces"].as_array().unwrap().len(), 1); assert_eq!(second_snapshot["terminals"].as_array().unwrap().len(), 1); + assert_eq!(second_snapshot["terminals"][0]["id"], created_path["terminal_id"]); let after_second_restart = wait_for_host_records(&harness.host_root(), 1).remove(0).1; assert_eq!(after_second_restart.host_pid, before.host_pid); assert_eq!(after_second_restart.host_start_nonce, before.host_start_nonce); @@ -3269,6 +3502,100 @@ fn wait_for_screen(path: &Path, surface: u64, marker: &str) -> String { last } +#[cfg(target_os = "macos")] +fn wait_for_stream_disconnect(stream: &mut UnixStream, timeout: Duration) -> bool { + struct Kqueue(libc::c_int); + + impl Drop for Kqueue { + fn drop(&mut self) { + // SAFETY: this helper owns the kqueue descriptor until drop. + unsafe { libc::close(self.0) }; + } + } + + let deadline = Instant::now() + timeout; + // SAFETY: kqueue returns a new descriptor owned by this helper. + let queue = Kqueue(unsafe { libc::kqueue() }); + assert!( + queue.0 >= 0, + "create renderer disconnect observer: {}", + std::io::Error::last_os_error() + ); + let change = libc::kevent { + ident: stream.as_raw_fd() as libc::uintptr_t, + filter: libc::EVFILT_READ, + flags: libc::EV_ADD | libc::EV_ENABLE | libc::EV_CLEAR, + fflags: 0, + data: 0, + udata: std::ptr::null_mut(), + }; + // SAFETY: change registers the live renderer socket in the owned kqueue. + let registered = + unsafe { libc::kevent(queue.0, &change, 1, std::ptr::null_mut(), 0, std::ptr::null()) }; + assert_eq!( + registered, + 0, + "register renderer disconnect observer: {}", + std::io::Error::last_os_error() + ); + + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + let timeout = libc::timespec { + tv_sec: remaining.as_secs().try_into().unwrap_or(libc::time_t::MAX), + tv_nsec: remaining.subsec_nanos().into(), + }; + let mut event = std::mem::MaybeUninit::::uninit(); + // SAFETY: event is writable for one kevent and timeout is valid for this call. + match unsafe { libc::kevent(queue.0, std::ptr::null(), 0, event.as_mut_ptr(), 1, &timeout) } + { + 1 => { + // SAFETY: kevent initialized exactly one event after returning one. + let event = unsafe { event.assume_init() }; + if event.filter == libc::EVFILT_READ && event.flags & libc::EV_EOF != 0 { + return true; + } + } + 0 => return false, + _ if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted => { + continue; + } + _ => panic!("renderer disconnect observer failed: {}", std::io::Error::last_os_error()), + } + } +} + +#[cfg(not(target_os = "macos"))] +fn wait_for_stream_disconnect(stream: &mut UnixStream, timeout: Duration) -> bool { + let timeout = timeout.as_millis().clamp(1, i32::MAX as u128) as i32; + let mut descriptor = libc::pollfd { fd: stream.as_raw_fd(), events: 0, revents: 0 }; + // SAFETY: descriptor points to the live renderer socket for this call. + match unsafe { libc::poll(&raw mut descriptor, 1, timeout) } { + 1 => descriptor.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0, + 0 => false, + _ => panic!("renderer disconnect observer failed: {}", std::io::Error::last_os_error()), + } +} + +fn adopted_terminal(path: &Path, terminal_id: &str, incarnation: &str, failure: &str) -> u64 { + let resolved = request( + path, + serde_json::json!({ + "cmd": "resolve-terminal", + "terminal_id": terminal_id, + }), + ); + assert_eq!(resolved["lifecycle"], "running", "{failure}: {resolved}"); + assert_eq!( + resolved["terminal_incarnation"].as_str(), + Some(incarnation), + "{failure}: {resolved}" + ); + resolved["surface"].as_u64().unwrap_or_else(|| panic!("{failure}: {resolved}")) +} + fn wait_for_host_records(root: &Path, expected: usize) -> Vec<(PathBuf, TerminalHostRecord)> { let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -3296,39 +3623,6 @@ fn wait_for_no_host_records(root: &Path) { panic!("terminal host records or exit sidecars remained after close: {records:?}; {exits:?}"); } -fn wait_for_socket_hangup(stream: &UnixStream, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return false; - } - let mut descriptor = libc::pollfd { - fd: stream.as_raw_fd(), - events: libc::POLLIN | libc::POLLHUP, - revents: 0, - }; - let timeout_ms = - remaining.min(Duration::from_millis(100)).as_millis().clamp(1, i32::MAX as u128) as i32; - // SAFETY: descriptor points to one initialized pollfd and the stream - // remains borrowed for the duration of poll. - let ready = unsafe { libc::poll(&raw mut descriptor, 1, timeout_ms) }; - if ready < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; - } - panic!("poll stalled renderer: {error}"); - } - if descriptor.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 { - return true; - } - if ready > 0 { - std::thread::sleep(Duration::from_millis(1)); - } - } -} - fn wait_for_terminal_lifecycle( socket: &Path, terminal_id: &str, @@ -3401,6 +3695,15 @@ fn process_exists(pid: libc::pid_t) -> bool { || std::io::Error::last_os_error().kind() == std::io::ErrorKind::PermissionDenied } +fn process_is_active(pid: libc::pid_t) -> bool { + let output = Command::new("ps") + .args(["-o", "stat=", "-p", &pid.to_string()]) + .output() + .expect("inspect process state"); + output.status.success() + && output.stdout.iter().copied().find(|byte| !byte.is_ascii_whitespace()) != Some(b'Z') +} + fn wait_for_host_size(root: &Path, cols: u16, rows: u16) { let deadline = Instant::now() + Duration::from_secs(10); loop { diff --git a/cmux-tui/crates/ghostty-vt-sys/build.rs b/cmux-tui/crates/ghostty-vt-sys/build.rs index 5217c814a6a0..41e396c332cd 100644 --- a/cmux-tui/crates/ghostty-vt-sys/build.rs +++ b/cmux-tui/crates/ghostty-vt-sys/build.rs @@ -2,6 +2,9 @@ use std::env; use std::path::{Path, PathBuf}; use std::process::Command; +const GHOSTTY_VT_SOURCE_PATHS: &[&str] = + &["VERSION", "build.zig", "build.zig.zon", "include", "pkg", "src"]; + fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); @@ -25,16 +28,9 @@ fn main() { println!("cargo:rerun-if-env-changed=CMUX_GHOSTTY_SRC"); println!("cargo:rerun-if-env-changed=ZIG"); println!("cargo:rerun-if-env-changed=CMUX_GHOSTTY_VT_ZIG_CPU"); - emit_cargo_path_directive("rerun-if-changed", &ghostty_dir.join("include")); - // Keep the generated binding fingerprint tied to the public terminal API. - // Hosted build caches can otherwise restore bindings from an older - // submodule revision even after checkout replaced this header. - emit_cargo_path_directive( - "rerun-if-changed", - &ghostty_dir.join("include/ghostty/vt/terminal.h"), - ); - emit_cargo_path_directive("rerun-if-changed", &ghostty_dir.join("build.zig")); - emit_cargo_path_directive("rerun-if-changed", &ghostty_dir.join("src")); + for path in GHOSTTY_VT_SOURCE_PATHS { + emit_cargo_path_directive("rerun-if-changed", &ghostty_dir.join(path)); + } // Build libghostty-vt.a with zig. ReleaseFast regardless of the cargo // profile: the VT parser is on the PTY hot path and a debug zig build diff --git a/cmux-tui/crates/ghostty-vt-sys/tests/build_invalidation.rs b/cmux-tui/crates/ghostty-vt-sys/tests/build_invalidation.rs new file mode 100644 index 000000000000..9539dc0d99f3 --- /dev/null +++ b/cmux-tui/crates/ghostty-vt-sys/tests/build_invalidation.rs @@ -0,0 +1,104 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct TestTree { + root: PathBuf, +} + +impl TestTree { + fn new() -> Self { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let root = std::env::temp_dir() + .join(format!("cmux-ghostty-vt-invalidation-{}-{nonce}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + Self { root } + } +} + +impl Drop for TestTree { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn run_build(workspace: &Path, ghostty: &Path, target: &Path, zig: &Path, log: &Path) { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let output = Command::new(cargo) + .current_dir(workspace) + .args(["build", "--quiet", "-p", "ghostty-vt-sys"]) + .env("CMUX_GHOSTTY_SRC", ghostty) + .env("CARGO_TARGET_DIR", target) + .env("ZIG", zig) + .env("FAKE_ZIG_LOG", log) + .output() + .unwrap(); + assert!( + output.status.success(), + "nested ghostty-vt-sys build failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn native_archive_rebuilds_when_a_ghostty_package_source_changes() { + let tree = TestTree::new(); + let ghostty = tree.root.join("ghostty"); + fs::create_dir_all(ghostty.join("include/ghostty")).unwrap(); + fs::create_dir(ghostty.join("src")).unwrap(); + fs::create_dir_all(ghostty.join("pkg/simdutf")).unwrap(); + fs::write(ghostty.join("build.zig"), b"// fake build").unwrap(); + fs::write(ghostty.join("build.zig.zon"), b".{}").unwrap(); + fs::write(ghostty.join("include/ghostty/vt.h"), b"typedef unsigned short GhosttyMode;\n") + .unwrap(); + fs::write(ghostty.join("src/main.zig"), b"// fake source").unwrap(); + let package_input = ghostty.join("pkg/simdutf/input"); + fs::write(&package_input, b"one").unwrap(); + + let zig = tree.root.join("fake-zig"); + fs::write( + &zig, + br#"#!/bin/sh +set -eu +printf 'run\n' >> "$FAKE_ZIG_LOG" +prefix= +while [ "$#" -gt 0 ]; do + if [ "$1" = "--prefix" ]; then + shift + prefix=$1 + fi + shift +done +test -n "$prefix" +mkdir -p "$prefix/lib" +printf 'void cmux_ghostty_vt_test_archive(void) {}\n' > "$prefix/empty.c" +/usr/bin/cc -c "$prefix/empty.c" -o "$prefix/empty.o" +/usr/bin/ar crs "$prefix/lib/libghostty-vt.a" "$prefix/empty.o" +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&zig).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&zig, permissions).unwrap(); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest_dir.ancestors().nth(2).unwrap(); + let target = tree.root.join("target"); + let log = tree.root.join("zig.log"); + run_build(workspace, &ghostty, &target, &zig, &log); + assert_eq!(fs::read_to_string(&log).unwrap().lines().count(), 1); + + fs::write(package_input, b"two").unwrap(); + run_build(workspace, &ghostty, &target, &zig, &log); + + assert_eq!( + fs::read_to_string(&log).unwrap().lines().count(), + 2, + "Ghostty package changes did not rerun the native build script" + ); +} diff --git a/cmux-tui/dist/RELEASING-TUI.md b/cmux-tui/dist/RELEASING-TUI.md index 2832ed028c83..52cf7caecc4d 100644 --- a/cmux-tui/dist/RELEASING-TUI.md +++ b/cmux-tui/dist/RELEASING-TUI.md @@ -8,9 +8,16 @@ TUI distribution versions are independent of the SDK version. SDKs publish as `cmux-sdk` on npm and PyPI, so the `cmux` name remains exclusive to this TUI release path. -The TUI does not store its version in a checked-in manifest. The packaging -scripts receive `--version`, so cutting a stable TUI release is just creating a -`cmux-tui-vX.Y.Z` tag on `main`. +The TUI does not store its distribution version in a checked-in manifest. The +packaging workflow stamps its `version` input into every binary so local +clients can distinguish releases even though the Cargo crate version remains +fixed. Cutting a stable TUI release is just creating a `cmux-tui-vX.Y.Z` tag on +`main`. + +The workflow also stamps the repository and terminal-engine commits. Ordinary +source builds derive those identities automatically and add a deterministic +content fingerprint for dirty trees. Explicit workflow stamps remain the +canonical distribution identity. ## Packages @@ -24,6 +31,11 @@ scripts receive `--version`, so cutting a stable TUI release is just creating a Linux packages contain static musl binaries that run on both glibc and musl distributions. PyPI publishes each Linux binary under matching manylinux and musllinux wheel tags so installers on both runtime families can resolve it. +The runtime requires Linux 5.1 or newer, a mounted `/proc`, and permission to +call `pidfd_send_signal`. + +macOS packages require macOS 15 or newer. The shared deployment-target file +sets both wheel compatibility tags and the minimum version in each Mach-O. ## One-time registry setup diff --git a/cmux-tui/dist/macos-deployment-target.txt b/cmux-tui/dist/macos-deployment-target.txt new file mode 100644 index 000000000000..fe6b09a7d14c --- /dev/null +++ b/cmux-tui/dist/macos-deployment-target.txt @@ -0,0 +1 @@ +15.0 diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index b8683888f50d..abc47aaf7128 100644 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -8,6 +8,7 @@ // signals so cmux behaves exactly like the native binary. const { spawnSync } = require("child_process"); +const { version } = require("../package.json"); const PACKAGE_BY_PLATFORM = { "darwin-arm64": "cmux-tui-darwin-arm64", @@ -28,6 +29,8 @@ if (!pkg) { } const binName = process.platform === "win32" ? "cmux-tui.exe" : "cmux-tui"; +const launcherCommand = + process.env.npm_command === "exec" ? `npx cmux@${version}` : "cmux"; let binPath; try { @@ -40,7 +43,14 @@ try { process.exit(1); } -const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" }); +const result = spawnSync(binPath, process.argv.slice(2), { + argv0: "cmux", + env: { + ...process.env, + CMUX_TUI_LAUNCHER_COMMAND: launcherCommand, + }, + stdio: "inherit", +}); if (result.error) { console.error(`cmux: failed to launch ${binPath}: ${result.error.message}`); diff --git a/cmux-tui/dist/scripts/package_pypi.py b/cmux-tui/dist/scripts/package_pypi.py index 60955dd48c8b..d149f4b687e3 100644 --- a/cmux-tui/dist/scripts/package_pypi.py +++ b/cmux-tui/dist/scripts/package_pypi.py @@ -19,6 +19,17 @@ DIST_NAME = "cmux" PACKAGE_NAME = "cmux_tui" ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) +MACOS_DEPLOYMENT_TARGET = ( + Path(__file__).resolve().parents[1] / "macos-deployment-target.txt" +).read_text().strip() +if not re.fullmatch(r"[0-9]+\.[0-9]+", MACOS_DEPLOYMENT_TARGET): + raise RuntimeError("invalid macOS deployment target") +macos_major, macos_minor = (int(part) for part in MACOS_DEPLOYMENT_TARGET.split(".")) +if macos_major >= 11 and macos_minor != 0: + raise RuntimeError( + "macOS 11+ wheel compatibility floors must use a major .0 release" + ) +MACOS_WHEEL_VERSION = MACOS_DEPLOYMENT_TARGET.replace(".", "_") @dataclass(frozen=True) @@ -28,8 +39,8 @@ class Target: TARGETS = [ - Target("aarch64-apple-darwin", ("macosx_11_0_arm64",)), - Target("x86_64-apple-darwin", ("macosx_10_12_x86_64",)), + Target("aarch64-apple-darwin", (f"macosx_{MACOS_WHEEL_VERSION}_arm64",)), + Target("x86_64-apple-darwin", (f"macosx_{MACOS_WHEEL_VERSION}_x86_64",)), Target( "x86_64-unknown-linux-musl", ( @@ -101,15 +112,61 @@ def wheel_bytes( ( f"{PACKAGE_NAME}/_main.py", text_bytes( - """from __future__ import annotations + f"""from __future__ import annotations import os import pathlib +import shlex import sys +PACKAGE_VERSION = {version!r} + + +def _launcher_command(argv0: str) -> str: + parts = pathlib.PurePath(argv0).parts + uv_cache = os.environ.get("UV_CACHE_DIR") + if uv_cache and argv0: + executable = pathlib.PurePath(os.path.abspath(os.path.expanduser(argv0))) + cache = pathlib.PurePath(os.path.abspath(os.path.expanduser(uv_cache))) + try: + relative = executable.relative_to(cache) + except ValueError: + pass + else: + if any( + candidate.startswith("archive-v") + and candidate[len("archive-v") :].isdigit() + for candidate in relative.parts + ): + return "uvx cmux==" + PACKAGE_VERSION + for index, part in enumerate(parts): + if part != "uv": + continue + if any( + candidate.startswith("archive-v") + and candidate[len("archive-v") :].isdigit() + for candidate in parts[index + 1 :] + ): + return "uvx cmux==" + PACKAGE_VERSION + pipx_seen = False + for index, part in enumerate(parts): + if part == "pipx": + pipx_seen = True + continue + if ( + pipx_seen + and len(part) == 15 + and all(character in "0123456789abcdef" for character in part) + and index + 1 < len(parts) + and parts[index + 1] == "bin" + ): + return "pipx run --spec cmux==" + PACKAGE_VERSION + " cmux" + return shlex.quote(argv0) if argv0 else "cmux" + def main() -> None: binary = pathlib.Path(__file__).resolve().parent / "bin" / "cmux-tui" + os.environ["CMUX_TUI_LAUNCHER_COMMAND"] = _launcher_command(sys.argv[0]) os.execv(str(binary), ["cmux", *sys.argv[1:]]) """ ), diff --git a/cmux-tui/dist/scripts/test_package_pypi.py b/cmux-tui/dist/scripts/test_package_pypi.py new file mode 100644 index 000000000000..eeb762d472a0 --- /dev/null +++ b/cmux-tui/dist/scripts/test_package_pypi.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Regression tests for the generated PyPI launcher.""" + +from __future__ import annotations + +import os +import unittest +from pathlib import Path +from unittest.mock import patch + +from package_pypi import TARGETS, wheel_bytes + + +def launcher_command(version: str, argv0: str) -> str: + files = wheel_bytes(version, "test-platform", b"binary", b"hook-binary") + source = next(data for name, data, _ in files if name == "cmux_tui/_main.py") + namespace: dict[str, object] = {} + exec(compile(source, "cmux_tui/_main.py", "exec"), namespace) + return namespace["_launcher_command"](argv0) # type: ignore[operator] + + +class LauncherCommandTests(unittest.TestCase): + def test_launcher_remains_compatible_with_python_3_8(self) -> None: + files = wheel_bytes("1.2.3", "test-platform", b"binary", b"hook-binary") + source = next(data for name, data, _ in files if name == "cmux_tui/_main.py") + + self.assertNotIn(b".removeprefix(", source) + + def test_uv_cache_restarts_the_same_version(self) -> None: + self.assertEqual( + launcher_command( + "1.2.3", + "/Users/test/Library/Caches/uv/archive-v0/abcdefgh/bin/cmux", + ), + "uvx cmux==1.2.3", + ) + + def test_custom_uv_cache_restarts_the_same_version(self) -> None: + with patch.dict(os.environ, {"UV_CACHE_DIR": "/tmp/uv-cache"}): + self.assertEqual( + launcher_command( + "1.2.3", + "/tmp/uv-cache/archive-v0/abcdefgh/bin/cmux", + ), + "uvx cmux==1.2.3", + ) + + def test_pipx_run_cache_restarts_the_same_version(self) -> None: + self.assertEqual( + launcher_command( + "1.2.3", + "/Users/test/Library/Caches/pipx/0123456789abcde/bin/cmux", + ), + "pipx run --spec cmux==1.2.3 cmux", + ) + + def test_pipx_run_dot_cache_restarts_the_same_version(self) -> None: + self.assertEqual( + launcher_command( + "1.2.3", + "/Users/test/.local/pipx/.cache/0123456789abcde/bin/cmux", + ), + "pipx run --spec cmux==1.2.3 cmux", + ) + + def test_stable_install_path_is_reused_directly(self) -> None: + self.assertEqual( + launcher_command("1.2.3", "/Users/test/.local/bin/cmux"), + "/Users/test/.local/bin/cmux", + ) + + +class DarwinCompatibilityTests(unittest.TestCase): + def test_wheels_require_the_first_representable_safe_macos_floor(self) -> None: + tags = { + target.rust_target: target.platform_tags + for target in TARGETS + if target.rust_target.endswith("-apple-darwin") + } + + self.assertEqual( + tags, + { + "aarch64-apple-darwin": ("macosx_15_0_arm64",), + "x86_64-apple-darwin": ("macosx_15_0_x86_64",), + }, + ) + + def test_release_build_and_wheels_share_one_macos_minimum(self) -> None: + compatibility_file = Path(__file__).resolve().parents[1] / "macos-deployment-target.txt" + self.assertEqual(compatibility_file.read_text().strip(), "15.0") + + workflow = ( + Path(__file__).resolve().parents[3] + / ".github" + / "workflows" + / "cmux-tui-build-package.yml" + ).read_text() + self.assertGreaterEqual(workflow.count("dist/macos-deployment-target.txt"), 2) + readme = Path(__file__).resolve().parents[2] / "README.md" + self.assertIn("macOS 15 or newer", readme.read_text()) + +if __name__ == "__main__": + unittest.main() diff --git a/cmux-tui/dist/scripts/verify_artifact_identity.py b/cmux-tui/dist/scripts/verify_artifact_identity.py new file mode 100644 index 000000000000..81c0a39b259f --- /dev/null +++ b/cmux-tui/dist/scripts/verify_artifact_identity.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Execute a packaged cmux TUI artifact and verify its stamped identity.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--binary", required=True, type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--build-commit", required=True) + parser.add_argument("--ghostty-commit", required=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + expected = ( + f"cmux {args.version} " + f"({args.build_commit}; ghostty {args.ghostty_commit})" + ) + try: + completed = subprocess.run( + [str(args.binary), "--version"], + check=False, + capture_output=True, + text=True, + ) + except OSError as error: + raise SystemExit(f"artifact execution failed: {error}") from error + + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise SystemExit( + f"artifact execution failed with status {completed.returncode}: {detail}" + ) + + lines = completed.stdout.splitlines() + if lines != [expected] or completed.stderr: + actual = completed.stdout.rstrip("\r\n") + detail = f"; stderr: {completed.stderr.strip()}" if completed.stderr else "" + raise SystemExit( + f"identity mismatch: expected {expected!r}, got {actual!r}{detail}" + ) + + print(expected) + + +if __name__ == "__main__": + main() diff --git a/cmux-tui/dist/valgrind-pidfd-skips.txt b/cmux-tui/dist/valgrind-pidfd-skips.txt new file mode 100644 index 000000000000..a8d24a9ed669 --- /dev/null +++ b/cmux-tui/dist/valgrind-pidfd-skips.txt @@ -0,0 +1,303 @@ +# Tests whose exact Linux process signaling or scheduling cannot be preserved +# when Valgrind cannot emulate pidfd_send_signal. Native Linux CI runs every +# test. This list applies only after Valgrind's syscall probe fails. +browser_runtime browser_capture_scale_applies_to_metrics_screencast_and_input +browser_runtime browser_tab_creation_is_async_and_surfaces_bootstrap_failure +browser_runtime control_command_reports_backpressure_when_worker_queue_is_full +browser_runtime queued_back_and_forward_do_not_collapse_while_worker_is_blocked +browser_runtime socket_browser_attach_streams_frames_input_and_cell_pixels +browser_runtime stalled_external_browser_nudges_target_once_before_interaction +browser_runtime wedged_browser_navigate_does_not_block_same_socket_connection +cmux_tui_core mux::tests::apply_layout_holds_target_workspace_lifecycle_through_commit +cmux_tui_core mux::tests::blocked_terminal_adoption_does_not_stall_an_unrelated_terminal +cmux_tui_core mux::tests::browser_runtime_connection_does_not_hold_the_shared_slot +cmux_tui_core mux::tests::bulk_surface_close_uses_one_shared_termination_deadline +cmux_tui_core mux::tests::cleanup_scheduled_during_the_final_retry_gets_a_fresh_retry_budget +cmux_tui_core mux::tests::closing_a_view_preserves_terminal_side_tables_until_explicit_close +cmux_tui_core mux::tests::concurrent_browser_and_terminal_share_empty_workspace_screen +cmux_tui_core mux::tests::concurrent_browser_tabs_materialize_one_empty_workspace_screen +cmux_tui_core mux::tests::concurrent_empty_workspace_terminal_inherits_the_first_terminals_cwd +cmux_tui_core mux::tests::daemon_exit_retains_active_local_owner_until_termination_succeeds +cmux_tui_core mux::tests::daemon_exit_retries_until_async_browser_bootstrap_releases_ownership +cmux_tui_core mux::tests::daemon_handoff_does_not_terminate_an_in_flight_terminal_adoption +cmux_tui_core mux::tests::durable_workspace_creation_supports_the_in_process_terminal_runtime +cmux_tui_core mux::tests::failed_new_pane_attachment_retains_shutdown_ownership +cmux_tui_core mux::tests::failed_surface_shutdown_retains_process_ownership_for_retry +cmux_tui_core mux::tests::key_close_cannot_rebind_a_live_durable_workspace_identity +cmux_tui_core mux::tests::killed_kitty_surface_cannot_block_the_next_terminal_reservation +cmux_tui_core mux::tests::kitty_quota_worker_exhausts_deadline_pool_admission_rejections +cmux_tui_core mux::tests::layout_undo_retires_removed_surface_owner_before_reusing_capacity +cmux_tui_core mux::tests::live_authority_install_and_rotation_preserve_open_pty +cmux_tui_core mux::tests::local_workspace_close_retries_cleanup_from_its_committed_receipt +cmux_tui_core mux::tests::new_browser_tab_materializes_selected_empty_workspace +cmux_tui_core mux::tests::ordinary_browser_close_terminates_the_owner_staged_during_removal +cmux_tui_core mux::tests::ordinary_surface_close_reconciles_a_transient_termination_failure +cmux_tui_core mux::tests::ordinary_surface_close_retains_failed_process_ownership_until_reconciled +cmux_tui_core mux::tests::pane_and_screen_close_wait_for_targeted_terminal_commit +cmux_tui_core mux::tests::pending_topology_accepts_current_host_reconnect_without_advancing_lifecycle +cmux_tui_core mux::tests::permanent_shutdown_owner_failure_stops_automatic_retry_sweeps +cmux_tui_core mux::tests::persistent_mux_restart_restores_auxiliary_resources_and_exact_replay +cmux_tui_core mux::tests::persistent_provider_managed_mux_keeps_registry_durable_and_lifecycle_guarded +cmux_tui_core mux::tests::projected_effect_preserves_restored_terminal_before_surface_adoption +cmux_tui_core mux::tests::provider_ownership_handoff_waits_for_an_entered_ordinary_close +cmux_tui_core mux::tests::rejected_shutdown_preflight_preserves_browser_ownership +cmux_tui_core mux::tests::resource_workspace_close_retries_cleanup_before_committed_success +cmux_tui_core mux::tests::restart_marks_reserved_terminal_without_host_record_exited_without_respawn +cmux_tui_core mux::tests::restart_keeps_exited_terminal_as_detached_receipt_until_explicit_close +cmux_tui_core mux::tests::restart_sidecar_restores_exact_wait_exit_and_emits_one_public_event +cmux_tui_core mux::tests::restart_waits_for_inflight_terminal_host_publication_before_marking_missing +cmux_tui_core mux::tests::retained_shutdown_owners_bound_future_surface_admission +cmux_tui_core mux::tests::run_materializes_active_empty_workspace +cmux_tui_core mux::tests::run_new_workspace_accepts_a_stable_caller_key +cmux_tui_core mux::tests::server_stop_persists_a_restartable_empty_topology +cmux_tui_core mux::tests::server_shutdown_cannot_pass_an_in_flight_surface_retirement +cmux_tui_core mux::tests::server_shutdown_clears_many_surfaces_with_one_tree_event +cmux_tui_core mux::tests::server_shutdown_keeps_topology_when_terminal_batch_commit_fails +cmux_tui_core mux::tests::server_shutdown_rejects_host_records_beyond_owner_capacity +cmux_tui_core mux::tests::server_shutdown_rejects_malformed_host_records_before_removing_topology +cmux_tui_core mux::tests::server_shutdown_reuses_one_deadline_bounded_record_scan +cmux_tui_core mux::tests::server_shutdown_uses_launched_runtime_when_target_confirmation_is_unreachable +cmux_tui_core mux::tests::server_shutdown_waits_for_async_browser_bootstrap +cmux_tui_core mux::tests::server_shutdown_waits_for_in_flight_surface_creation +cmux_tui_core mux::tests::surface_session_subscription_tracks_real_tab_moves_without_layout_churn +cmux_tui_core mux::tests::terminal_close_reports_exact_host_record_read_failure +cmux_tui_core mux::tests::terminal_move_and_destination_close_detach_view_in_one_registry_order +cmux_tui_core mux::tests::workspace_mutation_close_waits_for_targeted_terminal_commit_and_replays +cmux_tui_core process_session::tests::natural_reap_batch_refreshes_all_sessions_with_one_scan +cmux_tui_core process_session::tests::session_members_are_never_signaled_through_a_reusable_pid +cmux_tui_core process_session::tests::shutdown_treats_zombie_members_as_drained_before_reserved_parent +cmux_tui_core resource_router::auxiliary::tests::sidebar_resource_lifecycle_uses_the_real_plugin_pty_and_exact_receipts +cmux_tui_core resource_router::content::tests::one_terminal_can_be_detached_reprojected_and_closed_explicitly +cmux_tui_core resource_router::content::tests::terminal_close_terminates_a_restored_host_before_acknowledgement +cmux_tui_core resource_router::content::tests::terminal_external_effect_replays_before_selector_resolution +cmux_tui_core resource_router::content::tests::terminal_viewport_scroll_uses_one_bounded_receipt_without_session_journal_churn +cmux_tui_core resource_router::content::tests::terminal_wait_exit_resolves_detached_id_after_exit_upsert_then_delete +cmux_tui_core resource_router::content::tests::terminal_wait_exit_wakes_when_a_concurrent_terminal_close_tombstones_it +cmux_tui_core resource_router::topology::tests::pane_close_detaches_views_without_settling_terminal_exit_waits +cmux_tui_core server::tests::concurrent_terminal_close_settles_unbounded_connection_wait_exit +cmux_tui_core server::tests::connection_wait_exit_resolves_durable_detached_terminal_after_restart +cmux_tui_core server::tests::daemon_shutdown_atomically_fences_native_browser_ownership +cmux_tui_core server::tests::daemon_shutdown_force_preserves_the_identity_fence +cmux_tui_core server::tests::daemon_shutdown_is_local_fenced_and_queues_ack_first +cmux_tui_core server::tests::clear_history_does_not_block_unrelated_surface_input_on_one_connection +cmux_tui_core server::tests::guarded_browser_attach_rejects_a_late_capability_upgrade +cmux_tui_core server::tests::local_shutdown_exits_after_cleanup_when_the_peer_drops_before_ack +cmux_tui_core server::tests::local_shutdown_responds_before_requesting_mux_exit +cmux_tui_core server::tests::local_shutdown_waits_for_the_acknowledgement_write +cmux_tui_core server::tests::resource_shutdown_replay_reserves_handoff_and_retries_the_post_ack_exit +cmux_tui_core server::tests::resource_shutdown_requires_local_authority_and_force_for_a_live_browser_owner +cmux_tui_core server::tests::sidebar_resource_attach_acknowledges_before_styled_snapshot_and_cancels +cmux_tui_core server::tests::title_changed_event_includes_authoritative_surface_title +cmux_tui_core sidebar_resource::tests::fake_plugin_input_reaches_the_styled_render_attachment +cmux_tui_core surface::tests::explicit_shutdown_deadline_does_not_wait_for_natural_cleanup_sweep +cmux_tui_core surface::tests::failed_local_pty_initialization_reaps_spawned_child +cmux_tui_core surface::tests::local_child_observation_uses_the_reserved_shared_reaper +cmux_tui_core surface::tests::local_same_pair_alt_screen_roundtrip_forces_resolved_cursor_colors +cmux_tui_core surface::tests::local_session_kill_observes_the_waitable_leader_under_its_owner_lock +cmux_tui_core surface::tests::local_surface_retains_real_status_after_final_pty_bytes +cmux_tui_core surface::tests::normal_child_exit_cleans_same_session_descendants_before_reaping +cmux_tui_core surface::tests::normal_child_exit_retries_a_failed_session_cleanup +cmux_tui_core surface::tests::pty_eof_publishes_final_render_frame_before_surface_removal +cmux_tui_core terminal_host_runtime::unix::tests::failed_host_pty_initialization_reaps_spawned_child +cmux_tui_core terminal_host_runtime::unix::tests::hosted_child_exit_cleans_descendants_that_keep_the_slave_open +cmux_tui_core terminal_host_runtime::unix::tests::hosted_child_exit_retries_a_failed_session_cleanup +cmux_tui_core terminal_host_runtime::unix::tests::hosted_child_observation_uses_the_reserved_shared_reaper +cmux_tui_core terminal_host_runtime::unix::tests::hosted_session_kill_observes_the_waitable_leader_under_its_owner_lock +cmux_tui_core terminal_host_runtime::unix::tests::launch_failure_is_reported_before_bootstrap_pipe_closes +cmux_tui_core terminal_host_runtime::unix::tests::spawned_host_process_drop_transfers_waiting_to_the_shared_reaper +cmux_tui_core terminal_host_runtime::unix::tests::unauthenticated_host_client_handshake_is_time_bounded +cmux_tui app::tests::active_scrollbar_drag_rebases_after_terminal_output_changes_geometry +cmux_tui app::tests::admitted_tab_drag_survives_a_pending_pointer_map_refresh +cmux_tui app::tests::alt_directional_focus_traverses_sidebar_at_the_pane_boundary +cmux_tui app::tests::alt_n_rejects_a_new_pane_with_no_visible_content +cmux_tui app::tests::alt_n_uses_zellij_default_vertical_distribution +cmux_tui app::tests::attached_tab_mouse_click_uses_rendered_hit_and_survives_routing_refresh +cmux_tui app::tests::attached_workspace_mouse_down_uses_both_rendered_rows_and_survives_routing_refresh +cmux_tui app::tests::attached_workspace_release_without_press_is_a_no_op +cmux_tui app::tests::browser_drag_retargets_to_the_animated_pane_geometry +cmux_tui app::tests::browser_graphics_processing_does_not_block_terminal_pointer_input +cmux_tui app::tests::builtin_sidebar_registers_resize_hit_in_both_views +cmux_tui app::tests::captured_pty_motion_rejects_geometry_changed_since_press +cmux_tui app::tests::captured_release_ignores_destination_changes_and_clears_ownership +cmux_tui app::tests::catalog_refresh_preserves_machine_and_workspace_selection_identity_and_scroll +cmux_tui app::tests::clear_history_fallback_operations_remain_ordered +cmux_tui app::tests::clipped_terminal_input_uses_the_logical_source_column +cmux_tui app::tests::close_tab_action_honors_its_explicit_pane_target +cmux_tui app::tests::closing_connection_dialog_prevents_an_active_connect_from_replacing_the_session +cmux_tui app::tests::command_k_clears_prior_output_without_a_session_mutation +cmux_tui app::tests::command_k_matches_the_reported_physical_key_across_layouts +cmux_tui app::tests::command_k_reaches_a_kitty_app_on_the_alternate_screen +cmux_tui app::tests::contended_terminal_semantics_retain_discrete_press_for_replay +cmux_tui app::tests::ctrl_l_reaches_foreground_primary_screen_app +cmux_tui app::tests::deferred_click_fails_closed_after_pointer_map_generation_changes +cmux_tui app::tests::deferred_input_is_discarded_when_its_destination_changes +cmux_tui app::tests::deferred_scrollbar_press_cannot_reinterpret_changed_thumb_geometry +cmux_tui app::tests::deferred_tab_press_cannot_retarget_a_replacement_surface_at_the_same_index +cmux_tui app::tests::deferred_terminal_press_cannot_retarget_repainted_content +cmux_tui app::tests::deferred_terminal_press_fails_closed_when_live_mouse_ownership_changes +cmux_tui app::tests::deferred_untracked_wheel_fails_closed_when_screen_semantics_change +cmux_tui app::tests::delayed_clear_history_completion_preserves_newer_viewport_and_selection +cmux_tui app::tests::delayed_clear_history_completion_preserves_recreated_selection +cmux_tui app::tests::directional_focus_uses_client_history_and_visible_geometry +cmux_tui app::tests::disabled_mouse_snapshot_does_not_consume_press +cmux_tui app::tests::dispatcher_timeout_preserves_ambiguous_press_for_recovery_release +cmux_tui app::tests::doubled_prefix_keeps_the_focused_sidebar_plugin_target +cmux_tui app::tests::doubled_prefix_runs_the_shared_send_prefix_action +cmux_tui app::tests::enhanced_prefixed_split_survives_pointer_mutation_and_focus_refresh +cmux_tui app::tests::event_loop_renders_paint_before_following_pointer_input +cmux_tui app::tests::every_context_menu_exposes_sidebar_visibility +cmux_tui app::tests::evicted_known_undelivered_motion_allows_same_cell_retry +cmux_tui app::tests::failed_command_k_preserves_viewport_and_selection +cmux_tui app::tests::failed_machine_switch_preserves_the_current_session +cmux_tui app::tests::failed_mux_recovery_releases_barrier_and_discards_ambiguous_input +cmux_tui app::tests::failed_pty_owned_press_does_not_fall_through_to_cmux_mouse_actions +cmux_tui app::tests::failed_semantic_destination_discards_only_its_dependent_input +cmux_tui app::tests::files_filter_exposes_ratatui_cursor_and_accepts_mouse_cursor_placement +cmux_tui app::tests::files_sidebar_renders_temp_directory_and_unread_header_badge +cmux_tui app::tests::focus_loss_purges_pointer_press_waiting_for_a_paint +cmux_tui app::tests::focus_sidebar_focuses_builtin_sidebar +cmux_tui app::tests::focused_sidebar_tab_toggles_builtin_views_and_back +cmux_tui app::tests::focused_sidebar_uses_an_accent_header_and_divider +cmux_tui app::tests::graphics_scene_cache_rebuilds_only_the_dirty_surface_projection +cmux_tui app::tests::graphics_scene_cache_skips_text_only_snapshot_rebuilds +cmux_tui app::tests::guarded_mouse_encoding_rejects_a_changed_terminal_snapshot +cmux_tui app::tests::guarded_mouse_encoding_rejects_changed_terminal_geometry +cmux_tui app::tests::hiding_then_showing_sidebar_rehydrates_same_size_plugin_status +cmux_tui app::tests::horizontal_status_bar_reserves_terminal_cells_for_wide_messages +cmux_tui app::tests::horizontal_viewport_keeps_visible_kitty_placement_aligned +cmux_tui app::tests::immediate_split_receipt_routes_following_input_past_later_creation +cmux_tui app::tests::immediate_terminal_press_fails_closed_before_surface_output_marks_route_stale +cmux_tui app::tests::immediate_terminal_press_rejects_content_changed_before_surface_output +cmux_tui app::tests::immediate_untracked_wheel_fails_closed_before_surface_output_marks_route_stale +cmux_tui app::tests::in_place_machine_switch_preserves_rail_view_focus_and_widths +cmux_tui app::tests::input_never_reasserts_a_viewer_size +cmux_tui app::tests::key_typed_during_pending_sidebar_focus_follows_successful_focus +cmux_tui app::tests::key_typed_during_pending_sidebar_focus_keeps_pane_target_on_failure +cmux_tui app::tests::layout_undo_action_confirms_before_closing_a_created_pane +cmux_tui app::tests::machine_session_replacement_settles_pointer_capture_on_the_old_session +cmux_tui app::tests::modifier_only_press_preserves_selection_and_durable_notice +cmux_tui app::tests::modifier_only_presses_do_not_consume_prefix_before_shifted_binding +cmux_tui app::tests::mouse_wheel_scrolls_machine_and_workspace_rail_viewports_independently +cmux_tui app::tests::mux_recovery_barrier_defers_input_until_authoritative_tree_is_applied +cmux_tui app::tests::non_switch_machine_action_keeps_the_current_session_and_rails +cmux_tui app::tests::notify_unread_indicators_render_and_clear +cmux_tui app::tests::opening_shortcut_help_releases_an_active_browser_mouse_press +cmux_tui app::tests::opening_shortcut_help_releases_an_active_pty_mouse_press +cmux_tui app::tests::pairing_dialog_captures_live_non_left_pointer_input +cmux_tui app::tests::pane_context_maximize_focuses_the_explicit_inactive_pane +cmux_tui app::tests::pane_context_maximize_preserves_its_explicit_intent_after_remote_state_changes +cmux_tui app::tests::pane_context_new_pane_runs_the_same_smart_layout_action_as_alt_n +cmux_tui app::tests::pane_cursor_and_active_border_yield_to_builtin_and_plugin_sidebars +cmux_tui app::tests::pane_tab_context_menu_renames_the_exact_inactive_tab +cmux_tui app::tests::physical_release_queues_behind_a_contended_terminal_press +cmux_tui app::tests::plugin_sidebar_registers_resize_drag_hit +cmux_tui app::tests::pointer_input_waits_for_a_cached_surface_attach_claim +cmux_tui app::tests::pointer_motion_does_not_wait_for_terminal_parsing +cmux_tui app::tests::prefix_bar_and_shortcut_modal_use_the_resolved_action_catalog +cmux_tui app::tests::prefix_chord_is_resolved_before_deferred_session_barrier +cmux_tui app::tests::prefixed_clear_history_never_forwards_only_the_suffix_key +cmux_tui app::tests::prepared_machine_session_events_stay_paused_until_commit_activation +cmux_tui app::tests::projection_agent_rows_hide_finished_reports +cmux_tui app::tests::projection_enter_on_active_surface_returns_focus_to_pane +cmux_tui app::tests::projection_stale_action_selection_does_not_retarget_resource_row +cmux_tui app::tests::projection_workspace_row_activates_on_mouse_down +cmux_tui app::tests::pty_mouse_press_does_not_wait_for_terminal_parsing +cmux_tui app::tests::pty_mouse_tracking_forwards_click_release_and_wheel_with_shift_override +cmux_tui app::tests::pty_mouse_uses_the_canonical_rendered_grid_during_resize_margins +cmux_tui app::tests::rejected_motion_enqueue_rolls_back_mouse_encoder_dedupe +cmux_tui app::tests::rejected_release_clears_the_active_pty_drag +cmux_tui app::tests::remote_command_k_uses_authoritative_screen_and_keyboard_modes +cmux_tui app::tests::remote_screen_switch_records_the_new_active_pane +cmux_tui app::tests::remote_workspace_switch_records_the_new_active_pane +cmux_tui app::tests::repeated_split_then_ctrl_d_is_receipt_ordered_without_prefix_leaks +cmux_tui app::tests::replaced_session_ignores_old_surface_lane_completion +cmux_tui app::tests::replacement_provider_notice_cannot_mask_missing_workspace_mirror_error +cmux_tui app::tests::retained_right_button_capture_crosses_the_menu_frame_it_opens +cmux_tui app::tests::retiring_surface_state_releases_its_failed_input_lane +cmux_tui app::tests::reverse_viewport_sweep_cancels_a_queued_size_release_and_reasserts +cmux_tui app::tests::right_button_capture_cannot_cross_a_new_pairing_dialog +cmux_tui app::tests::selection_drag_and_release_bypass_a_pending_focus_mutation +cmux_tui app::tests::selection_drag_retargets_to_the_animated_pane_geometry +cmux_tui app::tests::sidebar_context_menu_focus_is_idempotent +cmux_tui app::tests::sidebar_headers_are_the_only_pointer_entrypoint_for_rail_focus +cmux_tui app::tests::sidebar_plugin_sync_is_deduped_after_it_is_applied +cmux_tui app::tests::single_surface_client_rejects_hidden_browser_creation +cmux_tui app::tests::single_surface_client_rejects_hidden_pane_closure +cmux_tui app::tests::single_surface_layout_uses_every_terminal_cell_without_chrome +cmux_tui app::tests::single_surface_machine_session_install_does_not_publish_global_cell_metrics +cmux_tui app::tests::single_surface_status_overlays_the_terminal_without_a_status_bar +cmux_tui app::tests::split_ratio_samples_coalesce_without_snapshots_before_final_settlement +cmux_tui app::tests::stale_layout_undo_confirmation_keeps_the_created_pane +cmux_tui app::tests::stale_session_events_are_ignored_after_an_in_place_switch +cmux_tui app::tests::status_message_context_menu_offers_full_message_copy +cmux_tui app::tests::status_message_drag_selection_highlights_the_visible_text +cmux_tui app::tests::surface_exit_retires_stale_view_until_authoritative_topology_refresh +cmux_tui app::tests::surface_only_context_menu_omits_client_management +cmux_tui app::tests::tab_selection_is_client_local +cmux_tui app::tests::tab_switch_moves_size_lease_without_dropping_hidden_surface +cmux_tui app::tests::tabs_column_context_menu_renames_the_exact_clicked_tab +cmux_tui app::tests::tabs_column_renders_selected_workspace_tabs_and_activates_through_native_focus +cmux_tui app::tests::terminal_geometry_mutations_enter_the_pointer_routing_lane +cmux_tui app::tests::terminal_paint_reuses_unchanged_pointer_owner_snapshots +cmux_tui app::tests::terminal_pointer_motion_encoding_stays_inline +cmux_tui app::tests::terminally_failed_retained_motion_does_not_block_a_later_key +cmux_tui app::tests::vertical_split_drag_keeps_nested_pane_tab_bars_visible +cmux_tui app::tests::viewport_columns_resize_with_shortcuts_and_mouse_drag +cmux_tui app::tests::viewport_geometry_changes_reveal_the_active_pane_without_canceling_manual_scroll +cmux_tui app::tests::viewport_pane_overflows_the_existing_tiled_layout +cmux_tui app::tests::viewport_resize_drag_keeps_its_mouse_down_coordinate_origin +cmux_tui app::tests::workspace_agent_tree_renders_collapses_and_renames_the_exact_surface +cmux_tui app::tests::workspace_keyboard_enter_returns_focus_to_pane +cmux_tui app::tests::workspace_rail_scrollbar_is_visible_clickable_and_draggable +cmux_tui config::tests::injected_ghostty_defaults_drive_headless_render_state +cmux_tui pty_input::tests::ambiguous_local_press_keeps_its_recovery_release_reservation +cmux_tui pty_input::tests::clean_terminal_exit_is_known_not_delivered_and_does_not_fail_its_lane +cmux_tui pty_input::tests::exit_observed_after_a_failed_write_keeps_delivery_ambiguous +cmux_tui server_lifecycle::legacy_process::tests::frozen_tree_drop_resumes_with_its_original_process_proof +cmux_tui server_lifecycle::tests::legacy_stop_terminates_only_the_identified_process +cmux_tui session::remote::tests::real_server_attach_and_resize_preserve_kitty_number_aliases +cmux_tui tests::failed_server_publication_preserves_live_socket +cmux_tui tests::remote_runtime_start_failure_cleans_published_server +pty attach_resize_replay_preserves_an_inflight_kitty_transmission +pty attach_stream_orders_resize_between_output_frames +pty attach_stream_replays_then_streams_without_duplication +pty byte_attach_between_transmit_and_place_keeps_the_unplaced_image +pty byte_attach_cursor_snapshot_does_not_fan_out_a_render_frame +pty control_socket_attach_palette_is_full_sparse_state_and_reset_clears_all_256 +pty control_socket_attach_serializes_kitty_aliases_on_initial_and_resize_replay +pty control_socket_attach_stream_receives_merged_colors_changed +pty control_socket_attach_vt_state_includes_effective_colors +pty control_socket_attach_vt_state_reports_authoritative_cursor_before_replay +pty control_socket_attach_vt_state_reports_builtin_cursor_without_config +pty control_socket_broadcasts_surface_resized_once_per_changed_size +pty control_socket_read_screen_reports_rendered_viewport_after_scrollback_clear +pty control_socket_round_trip +pty control_socket_wait_for_matches_one_shot_output_already_on_screen +pty create_empty_workspace_is_visible_and_materialized_in_place +pty default_byte_attach_replays_preexisting_kitty_image +pty default_colors_apply_to_existing_and_future_surfaces +pty headless_creation_uses_explicit_or_authoritative_client_size +pty new_tab_on_empty_headless_session_creates_workspace +pty read_scrollback_pages_oldest_rows_and_clamps_bounds +pty render_attach_headless_fans_one_frame_to_render_and_byte_consumers +pty render_attach_initial_snapshot_includes_unplaced_image_for_later_placement +pty render_attach_resize_is_a_full_replacement_at_the_new_size +pty render_attach_snapshot_and_raced_write_have_no_gap_or_duplicate_frame +pty render_attach_snapshot_contains_preexisting_kitty_image +pty send_paste_wraps_only_while_dec_mode_2004_is_enabled +pty surface_exit_detaches_terminal_view_and_emits_event +pty surface_resize_reports_whether_the_size_changed +pty surface_runs_command_and_screen_updates +pty terminal_surface_uses_only_its_explicit_geometry_authority +pty tree_event_modes_receive_delta_or_exact_coarse_fallback +websocket_transport clients_list_identify_resize_and_detach_across_transports +websocket_transport websocket_auth_accepts_exact_preamble_and_rejects_missing_or_wrong_tokens +websocket_transport websocket_keeps_authenticated_inbound_requests_at_four_mib +websocket_transport websocket_non_loopback_bind_requires_and_accepts_explicit_insecure_opt_in +websocket_transport websocket_pairing_is_approved_over_trusted_unix_and_credential_reconnects +websocket_transport websocket_rejects_oversized_authentication_frames +websocket_transport websocket_render_attach_carries_large_rgba_state_and_stays_connected +websocket_transport websocket_server_allows_pairing_without_a_static_token +websocket_transport websocket_server_rejects_tokens_that_cannot_fit_the_auth_limit +websocket_transport websocket_streams_subscribe_and_attach_and_survives_unclean_disconnect +websocket_transport websocket_subscriber_receives_cross_connection_event_without_poll_delay diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index 62be1237cd3d..694b162254c3 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -43,9 +43,18 @@ cargo run -p cmux-tui -- attach --session agents Detach from an attached TUI with prefix `d`. With default keys, that is `Ctrl-b d`. The server keeps running, and another `attach` reconnects to the same tree. PTY tabs attach with a Ghostty VT-state replay followed by a live output stream. +Local clients require the running server to have the same release identity. Inspect a session without attaching, or stop it before starting an upgraded binary: + +```bash +cargo run -p cmux-tui -- server status --session agents +cargo run -p cmux-tui -- server stop --session agents +``` + +Stopping the server exits its pane processes. `identify`, `server status`, and `server stop` remain available when client and server releases differ. + PTY programs may emit inline images with the Kitty graphics protocol. cmux-tui preserves those images across local, attached, and remote sessions when the outer terminal supports Kitty graphics, subject to the configured replay and transport byte limits; graphics beyond the replay budget may be omitted. -Attach one terminal without the sidebar, status bar, pane border, or other tabs: +Attach only one terminal by its stable ID. The terminal-only attachment omits the sidebar, status bar, pane border, and other tabs: ```bash cargo run -p cmux-tui -- attach --session agents --terminal diff --git a/cmux-tui/docs/protocol.md b/cmux-tui/docs/protocol.md index c2e1ea6eb532..8ac57f63c93f 100644 --- a/cmux-tui/docs/protocol.md +++ b/cmux-tui/docs/protocol.md @@ -23,7 +23,7 @@ $TMPDIR/cmux-tui-/.sock ```json {"id":1,"cmd":"identify"} -{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"...","protocol":12,"capabilities":["attach-initial-size","workspace-registry-v1","daemon-handoff-force-v1","browser-provider-v1","browser-pointer-frame-guard-v1","viewport-splits-v1","viewport-column-resize-v1","layout-undo-v1","clear-history-v1","surface-subscribe-filter","view-attachment-lease-v1","view-attachment-detach-v1","creation-receipts-v1","creation-attempt-keys-v1","creation-selector-fallbacks-v1","provider-managed-workspace-authority-v2","clear-history-key-v1"],"session":"main","pid":12345}} +{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"...","protocol":12,"capabilities":["attach-initial-size","workspace-registry-v1","daemon-handoff-force-v1","browser-provider-v1","browser-pointer-frame-guard-v1","viewport-splits-v1","viewport-column-resize-v1","layout-undo-v1","clear-history-v1","surface-subscribe-filter","view-attachment-lease-v1","view-attachment-detach-v1","creation-receipts-v1","creation-attempt-keys-v1","creation-selector-fallbacks-v1","provider-managed-workspace-authority-v2","clear-history-key-v1","server-shutdown-v1"],"session":"main","pid":12345,"lifecycle_ready":true,"shutdown_cleanup":{"pending":0,"retrying":false,"degraded":false}}} ``` Responses have this shape. The second example is a failed `clear-history` request: @@ -153,6 +153,10 @@ When the stream ends, it sends: ## Client Compatibility +Direct local private frontends require the server's distribution version, source build, terminal-engine build, and raw protocol to match exactly. Ordinary source builds derive both build identities automatically, including a deterministic fingerprint when the source tree is dirty. Private `identify`, ` server status`, and ` server stop` bypass that check so an upgraded client can inspect and stop an older server. `` is the current invocation, including `cmux-tui` for native binaries and `cmux` for packaged npm and Python installs. Local TUI attach paths reject a mismatch before loading or mutating session state. The noun-first CLI uses the separate `cmux.protocol/2` compatibility boundary, while explicit `raw command` calls carry no compatibility promise. + +Current servers apply one five-second deadline to fencing new surface creation, draining in-flight creation, committing hosted-terminal lifecycle changes in one registry transaction, clearing topology in one state mutation, and terminating each shared runtime once. A successful shutdown response confirms that cleanup completed within the shared deadline. When an older local server lacks orderly shutdown, the replacement client starts a detached cleanup helper. The helper reconnects, verifies from the Unix socket that the same reported server PID is still the connected peer, closes every surface, and then signals only that verified process. Detaching lets cleanup finish when closing the caller's pane exits the original client. + The remote TUI requires protocol v12. It rejects protocol-v11 servers because v12 adds lifecycle readiness to the strict identify response. Protocol v11 changed terminal placement nullability, terminal identity nullability, lifecycle typing, typed terminal exit records, and renderer minting responses. Protocol-v12 servers without `browser-pointer-frame-guard-v1` remain compatible for PTY surfaces, but the remote TUI rejects browser attachment because it cannot route browser pointer input safely. Every bundled client that opens a long-lived `attach-surface` socket sends `set-client-info` with `browser-pointer-frame-guard-v1` on that same connection before attaching a browser surface, because capability state and guarded-client pointer captures are scoped to the connection. Legacy one-shot pointer commands retain owner zero so a down/move/up sequence can remain compatible across short-lived sockets. Existing `set-ratio` clients remain source-compatible and the server keeps the pane-and-direction command unchanged. Protocol-v8 and newer frontends should read `layout.split` and send `set-split-ratio` so nested same-direction dividers are addressed exactly. Protocol v9 adds stack layout nodes and `new-pane`; clients must not send `new-pane` to a protocol-v8 server. Protocol v10 requires `surface` on every `set-client-sizing` request and moves `size_participating` into each `list-clients.sizes` entry. diff --git a/cmux-tui/scripts/smoke-tui.py b/cmux-tui/scripts/smoke-tui.py index 25f7b354cd9e..559200afbc1b 100644 --- a/cmux-tui/scripts/smoke-tui.py +++ b/cmux-tui/scripts/smoke-tui.py @@ -147,6 +147,23 @@ def tree(): def active_screen(ws): return next(s for s in ws["screens"] if s["active"]) +def wait_for_active_screen(predicate, seconds=15): + deadline = time.time() + seconds + last = None + while time.time() < deadline: + drain(0.2) + last = active_screen(tree()[0]) + if predicate(last): + return last + raise AssertionError(last) + + +def send_mouse_sequence(*events): + for index, event in enumerate(events): + os.write(fd, event) + if index + 1 < len(events): + drain(0.2) + def tree_has_surface(workspaces): return any( tab @@ -774,8 +791,7 @@ def assert_snapshot_parser_controls(): # Ctrl-b %: explicit horizontal split. Keep this as a literal byte sequence # so the smoke test covers the real prefix parser and production remote client. os.write(fd, b"\x02%") -drain(1.0) -screen0 = active_screen(tree()[0]) +screen0 = wait_for_active_screen(lambda screen: len(screen["panes"]) == 2) panes = screen0["panes"] assert len(panes) == 2, screen0 assert screen0["layout"]["type"] == "split" and screen0["layout"]["dir"] == "right", screen0 @@ -784,14 +800,22 @@ def assert_snapshot_parser_controls(): left_pane = panes[0] right_pane = panes[1] tab_order = [t["surface"] for t in left_pane["tabs"]] -os.write(fd, b"\x1b[<0;41;1M\x1b[<32;24;1M\x1b[<0;24;1m") -drain(1.0) -screen0 = active_screen(tree()[0]) +send_mouse_sequence(b"\x1b[<0;41;1M", b"\x1b[<32;24;1M", b"\x1b[<0;24;1m") +expected_order = [tab_order[2], tab_order[0], tab_order[1]] +screen0 = wait_for_active_screen( + lambda screen: [ + t["surface"] + for t in next(p for p in screen["panes"] if p["id"] == left_pane["id"])[ + "tabs" + ] + ] + == expected_order +) panes_by_id = {p["id"]: p for p in screen0["panes"]} left_pane = panes_by_id[left_pane["id"]] right_pane = panes_by_id[right_pane["id"]] reordered = [t["surface"] for t in left_pane["tabs"]] -assert reordered == [tab_order[2], tab_order[0], tab_order[1]], ( +assert reordered == expected_order, ( tab_order, reordered, screen0, @@ -799,9 +823,16 @@ def assert_snapshot_parser_controls(): ) print("tab drag reorder within pane ok") -os.write(fd, b"\x1b[<0;24;1M\x1b[<32;42;1M\x1b[<0;42;1m") -drain(1.0) -screen0 = active_screen(tree()[0]) +send_mouse_sequence(b"\x1b[<0;24;1M", b"\x1b[<32;42;1M", b"\x1b[<0;42;1m") +screen0 = wait_for_active_screen( + lambda screen: [ + t["surface"] + for t in next(p for p in screen["panes"] if p["id"] == left_pane["id"])[ + "tabs" + ] + ] + == tab_order +) panes_by_id = {p["id"]: p for p in screen0["panes"]} left_pane = panes_by_id[left_pane["id"]] end_reordered = [t["surface"] for t in left_pane["tabs"]] @@ -809,9 +840,23 @@ def assert_snapshot_parser_controls(): print("tab drag past last chip inserts at end ok") moving_surface = left_pane["tabs"][0]["surface"] -os.write(fd, b"\x1b[<0;27;1M\x1b[<32;63;1M\x1b[<0;63;1m") -drain(1.0) -screen0 = active_screen(tree()[0]) +send_mouse_sequence(b"\x1b[<0;27;1M", b"\x1b[<32;63;1M", b"\x1b[<0;63;1m") +screen0 = wait_for_active_screen( + lambda screen: moving_surface + not in [ + t["surface"] + for t in next(p for p in screen["panes"] if p["id"] == left_pane["id"])[ + "tabs" + ] + ] + and moving_surface + in [ + t["surface"] + for t in next(p for p in screen["panes"] if p["id"] == right_pane["id"])[ + "tabs" + ] + ] +) panes_by_id = {p["id"]: p for p in screen0["panes"]} assert moving_surface not in [t["surface"] for t in panes_by_id[left_pane["id"]]["tabs"]], screen0 assert moving_surface in [t["surface"] for t in panes_by_id[right_pane["id"]]["tabs"]], screen0 @@ -821,9 +866,16 @@ def assert_snapshot_parser_controls(): right_pane = panes_by_id[right_pane["id"]] content_surface = left_pane["tabs"][0]["surface"] right_before = [t["surface"] for t in right_pane["tabs"]] -os.write(fd, b"\x1b[<0;27;1M\x1b[<32;82;8M\x1b[<0;82;8m") -drain(1.0) -screen0 = active_screen(tree()[0]) +send_mouse_sequence(b"\x1b[<0;27;1M", b"\x1b[<32;82;8M", b"\x1b[<0;82;8m") +screen0 = wait_for_active_screen( + lambda screen: [ + t["surface"] + for t in next(p for p in screen["panes"] if p["id"] == right_pane["id"])[ + "tabs" + ] + ] + == right_before + [content_surface] +) panes_by_id = {p["id"]: p for p in screen0["panes"]} right_after = [t["surface"] for t in panes_by_id[right_pane["id"]]["tabs"]] assert right_after == right_before + [content_surface], (right_before, right_after, screen0) @@ -916,7 +968,7 @@ def assert_snapshot_parser_controls(): # row 1 blank, rows 2-3 workspace 1, row 4 blank, rows 5-6 workspace 2 # (SGR mouse coordinates are 1-based). original_ws = ws_id -os.write(fd, b"\x1b[<0;2;3M\x1b[<32;2;7M\x1b[<0;2;7m") +send_mouse_sequence(b"\x1b[<0;2;3M", b"\x1b[<32;2;7M", b"\x1b[<0;2;7m") drain(1.0) workspaces = tree() assert [w["id"] for w in workspaces] == [w["id"] for w in workspaces if w["id"] != original_ws] + [original_ws], workspaces @@ -1018,7 +1070,7 @@ def assert_snapshot_parser_controls(): for s in w["screens"] for p in s["panes"] ) -os.write(fd, b"\x1b[<2;81;6M\x1b[<34;81;10M\x1b[<2;81;10m") +send_mouse_sequence(b"\x1b[<2;81;6M", b"\x1b[<34;81;10M", b"\x1b[<2;81;10m") drain(1.0) tabs_after = sum( len(p["tabs"]) @@ -1065,7 +1117,7 @@ def assert_snapshot_parser_controls(): for p in s["panes"] ) # "Close tab" is the second row, directly below "Rename tab". -os.write(fd, b"\x1b[<2;81;6M\x1b[<34;81;7M\x1b[<2;81;7m") +send_mouse_sequence(b"\x1b[<2;81;6M", b"\x1b[<34;81;7M", b"\x1b[<2;81;7m") drain(1.0) tabs_after = sum( len(p["tabs"]) diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index a5f9e979da4d..69af8943f5e9 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -205,12 +205,14 @@ Params: none. Result: ```text -object{app:"cmux-tui",version:string,build_commit?:string|null,ghostty_commit?:string|null,protocol:uint32,capabilities:array,session:string,pid:uint32,registry_id:string,generation:string,workspace_revision:uint64} +object{app:"cmux-tui",version:string,build_commit?:string|null,ghostty_commit?:string|null,protocol:uint32,capabilities:array,session:string,pid:uint32,registry_id:string,generation:string,workspace_revision:uint64,terminal_revision:uint64,daemon_handoff:1,shutdown_cleanup?:object{pending:uint64,retrying:boolean,degraded:boolean}} ``` -`build_commit` and `ghostty_commit` are additive build-stamp fields. They are omitted or `null` when the binary was built without the corresponding stamp, so clients must preserve compatibility with older servers and unstamped local builds. +`build_commit` and `ghostty_commit` are additive build-stamp fields. Current source builds derive them from the repository and terminal-engine checkout. A dirty source build appends a deterministic content fingerprint. Distribution builds may override both stamps explicitly. Older binaries can omit the fields or return `null`, so clients must preserve compatibility with those identities. -`capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `daemon-handoff-force-v1` advertises the optional `force` field on `shutdown-daemon`. `browser-provider-v1` advertises the trusted-local, connection-scoped native browser provider lease used by cmux-browser and local automation. `browser-pointer-frame-guard-v1` advertises authoritative `pointer_frame_seq` and `pointer_frame_floor_seq` browser attach/frame state plus the additive `browser-frame-presented`, `browser-mouse-guarded`, and `browser-wheel-guarded` commands. Each admitted bitmap receives a new guard even when its document and dimensions match the previous bitmap. The reported floor through latest range proves route membership only. `browser-frame-presented` advances one exact acknowledged token for that connection, and only that token authorizes a new guarded pointer action. A guarded pointer command implicitly acknowledges its own token. Each connection retains one token, while the bounded browser input queue owns actions admitted before a later presentation. Navigation or geometry changes clear the range and all acknowledgements. An accepted press keeps its original guard for motion across ordinary repaints while document and geometry remain valid; invalidation suppresses further motion but retains its balancing release. A capable client echoes that value in `set-client-info`; browser attach requires the bilateral capability while PTY attach remains available without it. The legacy `browser-mouse` and `browser-wheel` schemas retain their optional guard, but guarded servers reject a missing guard before surface lookup. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `view-attachment-lease-v1` returns a connection-owned lease for each attach and enables lease-fenced sizing. `view-attachment-detach-v1` enables targeted stream cleanup. `creation-receipts-v1` enables idempotent destination creation, `creation-attempt-keys-v1` separates a stable correlation from the same-key or new-key execution attempt selected by `session.creation.resolve`, and `creation-selector-fallbacks-v1` adds bounded ordered destination continuations. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. +`capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `daemon-handoff-force-v1` advertises the optional `force` field on `shutdown-daemon`. `browser-provider-v1` advertises the trusted-local, connection-scoped native browser provider lease used by cmux-browser and local automation. `browser-pointer-frame-guard-v1` advertises authoritative `pointer_frame_seq` and `pointer_frame_floor_seq` browser attach/frame state plus the additive `browser-frame-presented`, `browser-mouse-guarded`, and `browser-wheel-guarded` commands. Each admitted bitmap receives a new guard even when its document and dimensions match the previous bitmap. The reported floor through latest range proves route membership only. `browser-frame-presented` advances one exact acknowledged token for that connection, and only that token authorizes a new guarded pointer action. A guarded pointer command implicitly acknowledges its own token. Each connection retains one token, while the bounded browser input queue owns actions admitted before a later presentation. Navigation or geometry changes clear the range and all acknowledgements. An accepted press keeps its original guard for motion across ordinary repaints while document and geometry remain valid; invalidation suppresses further motion but retains its balancing release. A capable client echoes that value in `set-client-info`; browser attach requires the bilateral capability while PTY attach remains available without it. The legacy `browser-mouse` and `browser-wheel` schemas retain their optional guard, but guarded servers reject a missing guard before surface lookup. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `view-attachment-lease-v1` returns a connection-owned lease for each attach and enables lease-fenced sizing. `view-attachment-detach-v1` enables targeted stream cleanup. `creation-receipts-v1` enables idempotent destination creation, `creation-attempt-keys-v1` separates a stable correlation from the same-key or new-key execution attempt selected by `session.creation.resolve`, and `creation-selector-fallbacks-v1` adds bounded ordered destination continuations. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. `server-shutdown-v1` advertises atomic destructive shutdown; clients may use the compatibility cleanup path only when this capability is absent. + +`shutdown_cleanup` reports unresolved shutdown work, whether a retry is active, and whether cleanup requires another explicit stop request. Its pending count includes terminal owners, unpublished child-reaper reservations, and ownerless post-fence work such as durable registry or browser-runtime cleanup. It is an additive protocol 11 field, so clients must accept its absence from older protocol 11 servers. Errors: @@ -224,7 +226,7 @@ CLI mapping: | --- | --- | | Verb | `identify` | | Flags | none | -| Plain stdout | `cmux-tui session= protocol= pid=` | +| Plain stdout | `cmux-tui session= protocol= pid= version=` | | JSON stdout | exact result object | | Exit codes | common | @@ -232,7 +234,7 @@ Example: ```json {"id":1,"cmd":"identify"} -{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","build_commit":"abc123","ghostty_commit":"def456","protocol":12,"capabilities":["attach-initial-size","surface-subscribe-filter","workspace-registry-v1","daemon-handoff-force-v1","browser-provider-v1","browser-pointer-frame-guard-v1","viewport-splits-v1","viewport-column-resize-v1","layout-undo-v1","clear-history-v1","clear-history-key-v1","view-attachment-lease-v1","view-attachment-detach-v1","creation-receipts-v1","creation-attempt-keys-v1","creation-selector-fallbacks-v1","provider-managed-workspace-authority-v2"],"session":"main","pid":12345}} +{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","build_commit":"abc123","ghostty_commit":"def456","protocol":12,"capabilities":["attach-initial-size","surface-subscribe-filter","workspace-registry-v1","daemon-handoff-force-v1","browser-provider-v1","browser-pointer-frame-guard-v1","viewport-splits-v1","viewport-column-resize-v1","layout-undo-v1","clear-history-v1","clear-history-key-v1","view-attachment-lease-v1","view-attachment-detach-v1","creation-receipts-v1","creation-attempt-keys-v1","creation-selector-fallbacks-v1","provider-managed-workspace-authority-v2","server-shutdown-v1"],"session":"main","pid":12345,"registry_id":"registry-123","generation":"generation-456","workspace_revision":12,"terminal_revision":34,"daemon_handoff":1,"lifecycle_ready":true,"shutdown_cleanup":{"pending":0,"retrying":false,"degraded":false}}} ``` The current server reports protocol `12` in this field and in `ping`. Clients must negotiate protocol 8 before requiring stable split ids or sending `set-split-ratio`, protocol 9 before decoding stack layouts or sending `new-pane`, protocol 10 before using per-surface client sizing, protocol 11 before decoding terminal lifecycle creation results or minting terminal renderer credentials, and protocol 12 before decoding lifecycle readiness from `identify`. @@ -300,6 +302,54 @@ Example: {"id":2,"ok":true,"data":{"ok":true,"version":"0.1.0","build_commit":"abc123","ghostty_commit":"def456","protocol":12}} ``` +### shutdown + +| Field | Value | +| --- | --- | +| name | `shutdown` | +| status | implemented | +| since | protocol 9 additive extension | + +Requests an orderly server exit after the success response is queued. The command is accepted only on the local Unix-domain socket. Stopping the mux exits every pane process. Shutdown uses one five-second deadline to fence new surface creation, drain in-flight creation, tombstone all hosted terminals in one registry transaction, clear the complete topology in one state mutation, and terminate pane runtimes. A success response means every tracked pane process was confirmed terminated before that deadline. + +Params: none. + +Result: `object{}`. + +Errors: + +| Error | Condition | +| --- | --- | +| `shutdown is only available over the local session socket` | Request arrived over WebSocket | +| `bad request: ...` | Malformed request envelope | + +CLI mapping: ` server stop`; ` server status` uses `identify` and does not issue this command. `` is the current executable invocation, such as `cmux-tui` for a native binary or `cmux` for the packaged npm and Python launchers. Upgrade recovery instructions use that same launcher for both stop and restart commands. When an older Unix server does not implement `shutdown`, a newer client verifies that the PID reported by `identify` owns the connected Unix socket, then starts a detached helper. The helper reconnects, verifies the same PID again, closes every surface reported by `list-workspaces`, and signals that verified process. Cleanup survives the caller's pane exit. If either socket-owner check or orderly pane cleanup fails, the helper leaves the server running and reports the failure. + +`cmux-tui server status` compares the local server's distribution version, source build, terminal-engine build, and protocol with the client. Plain output reports both versions and protocols plus localized mismatch reasons. JSON output is: + +```text +object{ + running:true, + compatible:boolean, + mismatch_reasons:array<"distribution-version"|"source-build"|"terminal-engine"|"protocol">, + server:object{ + version:string, + protocol:uint32, + shutdown_cleanup:object{pending:uint64,retrying:boolean,degraded:boolean} + }, + client:object{version:string,protocol:uint32} +} +``` + +`cmux-tui server stop` prints a localized confirmation. With `--json`, it prints `object{stopped:true}`. + +Example: + +```json +{"id":3,"cmd":"shutdown"} +{"id":3,"ok":true,"data":{}} +``` + ### set-client-info | Field | Value | diff --git a/cmux-tui/spec/inventory.json b/cmux-tui/spec/inventory.json index 9b237aee7829..4200943fd9d9 100644 --- a/cmux-tui/spec/inventory.json +++ b/cmux-tui/spec/inventory.json @@ -233,6 +233,7 @@ "detach-attached-view" ], "local-admin": [ + "shutdown", "register-browser-provider", "get-browser-provider", "unregister-browser-provider", diff --git a/cmux-tui/spec/sdk-schema.json b/cmux-tui/spec/sdk-schema.json index 4d135d807500..9a906a076fde 100644 --- a/cmux-tui/spec/sdk-schema.json +++ b/cmux-tui/spec/sdk-schema.json @@ -1728,6 +1728,36 @@ }, "additional_properties": false }, + "ShutdownCleanupStatus": { + "kind": "object", + "fields": { + "pending": { + "type": { + "kind": "scalar", + "name": "uint64" + }, + "presence": "required", + "nullable": false + }, + "retrying": { + "type": { + "kind": "scalar", + "name": "boolean" + }, + "presence": "required", + "nullable": false + }, + "degraded": { + "type": { + "kind": "scalar", + "name": "boolean" + }, + "presence": "required", + "nullable": false + } + }, + "additional_properties": false + }, "IdentifyResult": { "kind": "object", "fields": { @@ -1844,6 +1874,15 @@ "nullable": false, "since": 9 }, + "shutdown_cleanup": { + "type": { + "kind": "ref", + "name": "ShutdownCleanupStatus" + }, + "presence": "optional", + "nullable": false, + "since": 10 + }, "lifecycle_ready": { "type": { "kind": "scalar", @@ -9127,6 +9166,25 @@ "Only the calling connection's provider lease is removed." ] }, + "shutdown": { + "authority": "local-admin", + "since": 9, + "capability": "server-shutdown-v1", + "request": { + "kind": "object", + "fields": {}, + "additional_properties": false + }, + "result": { + "kind": "ref", + "name": "EmptyResult" + }, + "stream": null, + "constraints": [ + "The command is accepted only on a trusted Unix-classified transport.", + "Success means every tracked pane process terminated before the bounded shutdown deadline." + ] + }, "shutdown-daemon": { "authority": "local-admin", "since": 9, diff --git a/cmux-tui/spec/terminal-host.md b/cmux-tui/spec/terminal-host.md index 3f1b95569e38..eb03b06f187b 100644 --- a/cmux-tui/spec/terminal-host.md +++ b/cmux-tui/spec/terminal-host.md @@ -300,6 +300,15 @@ size and local scroll viewport. `HostHello` sent to the first authenticated launch owner. `Activate` has zero flags, request id zero, sequence zero, and an empty payload. +`FLAG_TERMINATE_ONLY` is bit 4 in protocol v4 and is valid only as the sole +flag in `ClientHello` and `HostHello`. The host grants only termination and +does not start a renderer stream. A version-2 record that advertises +`supports_terminate_only` uses legacy bit 2. A version-3 record that advertises +the capability uses legacy bit 3. Clients reject records that do not advertise +termination support. Version-4 hosts never accept legacy bit 2 or bit 3 as +termination authority, and old clients reject version-4 records before flag +interpretation. + ## Ordering and recovery A renderer applies every live sequence exactly once. A gap, duplicate, flagged frame without the required next `Colors`, or invalid flag is fatal. The renderer disconnects and obtains a new `Snapshot`; continuing from a damaged sequence would corrupt its mirror. diff --git a/cmux-tui/vendor/crossterm/src/style/types/colored.rs b/cmux-tui/vendor/crossterm/src/style/types/colored.rs index ab8d769d793c..c20582c4d992 100644 --- a/cmux-tui/vendor/crossterm/src/style/types/colored.rs +++ b/cmux-tui/vendor/crossterm/src/style/types/colored.rs @@ -153,58 +153,68 @@ impl fmt::Display for Colored { #[cfg(test)] mod tests { use crate::style::{Color, Colored}; + use serial_test::serial; fn check_format_color(colored: Colored, expected: &str) { Colored::set_ansi_color_disabled(true); - assert_eq!(colored.to_string(), ""); + let disabled = colored.to_string(); Colored::set_ansi_color_disabled(false); + assert_eq!(disabled, ""); assert_eq!(colored.to_string(), expected); } #[test] + #[serial] fn test_format_fg_color() { let colored = Colored::ForegroundColor(Color::Red); check_format_color(colored, "38;5;9"); } #[test] + #[serial] fn test_format_bg_color() { let colored = Colored::BackgroundColor(Color::Red); check_format_color(colored, "48;5;9"); } #[test] + #[serial] fn test_format_reset_fg_color() { let colored = Colored::ForegroundColor(Color::Reset); check_format_color(colored, "39"); } #[test] + #[serial] fn test_format_reset_bg_color() { let colored = Colored::BackgroundColor(Color::Reset); check_format_color(colored, "49"); } #[test] + #[serial] fn test_format_fg_rgb_color() { let colored = Colored::BackgroundColor(Color::Rgb { r: 1, g: 2, b: 3 }); check_format_color(colored, "48;2;1;2;3"); } #[test] + #[serial] fn test_format_fg_ansi_color() { let colored = Colored::ForegroundColor(Color::AnsiValue(255)); check_format_color(colored, "38;5;255"); } #[test] + #[serial] fn test_parse_ansi_fg() { test_parse_ansi(Colored::ForegroundColor) } #[test] + #[serial] fn test_parse_ansi_bg() { - test_parse_ansi(Colored::ForegroundColor) + test_parse_ansi(Colored::BackgroundColor) } /// Used for test_parse_ansi_fg and test_parse_ansi_bg diff --git a/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift b/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift index f875fe047dcf..7a8b3d434ef3 100644 --- a/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift +++ b/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift @@ -1,6 +1,7 @@ import AppKit import CmuxControlSocket import CmuxSettings +import CmuxTerminal import Testing #if canImport(cmux_DEV) @@ -33,7 +34,7 @@ import Testing /// The mark seam must NOT flag a mirror workspace's window for kill-on-close: /// the close detaches, the remote tmux session survives for resume. Before the /// fix this marked the window for kill; after, it never does. - @Test func markSeamDoesNotMarkMirrorForKill() throws { + @Test func markSeamDoesNotMarkMirrorForKill() async throws { let harness = try Harness() defer { harness.tearDown() } @@ -45,6 +46,7 @@ import Testing .windowsMarkedForKillOnClose() .contains(harness.windowId) ) + await harness.tearDownAndWait() } /// The v2 socket close path must detach a live last-workspace mirror without @@ -91,6 +93,7 @@ import Testing controller.cacheConnection(connection) #expect(try controller.mirrorSession(host: host, sessionName: "dev", into: harness.manager)) let mirrorWorkspace = try #require(harness.manager.tabs.first(where: { $0.isRemoteTmuxMirror })) + let teardownSurfaces = harness.captureTeardownSurfaces(in: [mirrorWorkspace]) let keepWorkspaceOpenKey = "closeWorkspaceOnLastSurfaceShortcut" let previousKeepWorkspaceOpen = UserDefaults.standard.object(forKey: keepWorkspaceOpenKey) UserDefaults.standard.set(false, forKey: keepWorkspaceOpenKey) @@ -130,6 +133,8 @@ import Testing #expect(!log.contains("kill-session"), Comment(rawValue: log)) #expect(controller.sessionMirror(host: host, sessionName: "dev") == nil) #expect(connection.exited) + await harness.waitForTeardown(of: teardownSurfaces) + await harness.tearDownAndWait() } /// Explicit detach of a mirror opened in its own window must close that @@ -171,6 +176,7 @@ import Testing controller.cacheConnection(connection) #expect(try controller.mirrorSession(host: host, sessionName: "dev", into: harness.manager)) let mirrorWorkspace = try #require(harness.manager.tabs.first(where: { $0.isRemoteTmuxMirror })) + let teardownSurfaces = harness.captureTeardownSurfaces(in: [mirrorWorkspace]) harness.manager.closeWorkspace(harness.workspace, recordHistory: false) #expect(harness.manager.tabs.map(\.id) == [mirrorWorkspace.id]) let owningWindow = try #require(harness.appDelegate.mainWindow(for: harness.windowId)) @@ -195,12 +201,14 @@ import Testing $0.windowId == harness.windowId }) #expect(harness.appDelegate.recoverableMainWindowRoute(windowId: harness.windowId) == nil) + await harness.waitForTeardown(of: teardownSurfaces) + await harness.tearDownAndWait() } /// A remote session ending removes its dead mirror but preserves the owning /// window with a fresh local workspace. Only explicit detach closes a /// dedicated final-mirror window. - @Test func remoteSessionEndOfDedicatedLastMirrorKeepsOwningWindowUsable() throws { + @Test func remoteSessionEndOfDedicatedLastMirrorKeepsOwningWindowUsable() async throws { let harness = try Harness() defer { harness.tearDown() } let host = RemoteTmuxHost(destination: "remote-end-\(UUID().uuidString)@example.test") @@ -209,6 +217,7 @@ import Testing controller.cacheConnection(connection) #expect(try controller.mirrorSession(host: host, sessionName: "dev", into: harness.manager)) let mirrorWorkspace = try #require(harness.manager.tabs.first(where: { $0.isRemoteTmuxMirror })) + let teardownSurfaces = harness.captureTeardownSurfaces(in: [mirrorWorkspace]) harness.manager.closeWorkspace(harness.workspace, recordHistory: false) let owningWindow = try #require(harness.appDelegate.mainWindow(for: harness.windowId)) @@ -221,6 +230,8 @@ import Testing #expect(harness.manager.tabs.count == 1) #expect(harness.manager.tabs.allSatisfy { !$0.isRemoteTmuxMirror }) #expect(!harness.manager.tabs.contains { $0.id == mirrorWorkspace.id }) + await harness.waitForTeardown(of: teardownSurfaces) + await harness.tearDownAndWait() } /// The ordinary non-last tab-close route shares the same detach contract as @@ -265,6 +276,7 @@ import Testing controller.cacheConnection(connection) #expect(try controller.mirrorSession(host: host, sessionName: "dev", into: harness.manager)) let mirrorWorkspace = try #require(harness.manager.tabs.first(where: { $0.isRemoteTmuxMirror })) + let teardownSurfaces = harness.captureTeardownSurfaces(in: [mirrorWorkspace]) #expect(harness.manager.tabs.count == 2) harness.manager.closeWorkspace(mirrorWorkspace, recordHistory: false) @@ -274,6 +286,8 @@ import Testing #expect(harness.manager.tabs.map(\.id) == [harness.workspace.id]) #expect(controller.sessionMirror(host: host, sessionName: "dev") == nil) #expect(connection.exited) + await harness.waitForTeardown(of: teardownSurfaces) + await harness.tearDownAndWait() } @Test func windowCreationFailureUsesLocalErrorMessage() { @@ -351,6 +365,13 @@ import Testing #expect(targetManager.tabs.filter(\.isRemoteTmuxMirror).count == 2) #expect(harness.manager.tabs.allSatisfy { !$0.isRemoteTmuxMirror }) #expect(secondManager.tabs.allSatisfy { !$0.isRemoteTmuxMirror }) + for windowID in extraWindowIDs.reversed() { + await harness.closeWindowAndWait(windowID) + } + extraWindowIDs.removeAll() + harness.controller.detach(host: host, sessionName: "one") + harness.controller.detach(host: host, sessionName: "two") + await harness.tearDownAndWait() } /// A direct socket caller must opt into focus. The CLI supplies an explicit @@ -429,6 +450,12 @@ import Testing #expect(focusedAfter["workspace_id"] as? String == workspaceIDBefore) #expect(focusedAfter["pane_id"] as? String == paneIDBefore) #expect(focusedAfter["surface_id"] as? String == surfaceIDBefore) + if let targetWindowID { + await harness.closeWindowAndWait(targetWindowID) + } + targetWindowID = nil + harness.controller.detach(host: host, sessionName: "one") + await harness.tearDownAndWait() } private func writeExecutable(at url: URL, contents: String) throws { @@ -467,6 +494,7 @@ import Testing let windowId: UUID let manager: TabManager let workspace: Workspace + let initialSurfaces: [TerminalSurface] var controller: RemoteTmuxController { appDelegate.remoteTmuxController } init() throws { @@ -474,6 +502,9 @@ import Testing windowId = appDelegate.createMainWindow() manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) workspace = try #require(manager.selectedWorkspace) + initialSurfaces = workspace.panels.values.compactMap { + ($0 as? TerminalPanel)?.surface + } } func tearDown() { @@ -483,6 +514,13 @@ import Testing closeWindow(windowId) } + func tearDownAndWait() async { + workspace.isRemoteTmuxMirror = false + // Clear any marker so it can't leak into another serialized test. + controller.consumeKillSessionsOnWindowClose(windowId: windowId) + await closeWindowAndWait(windowId) + } + func cacheConnection(host: RemoteTmuxHost, session: String) { controller.cacheConnection(RemoteTmuxControlConnection(host: host, sessionName: session)) } @@ -499,5 +537,43 @@ import Testing appDelegate.forgetRecoverableMainWindowRoute(windowId: id) RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) } + + func closeWindowAndWait(_ id: UUID) async { + let surfaces = terminalSurfaces(in: id) + closeWindow(id) + await waitForTeardown(of: surfaces) + } + + func waitForTeardown(of workspaces: [Workspace]) async { + await waitForTeardown(of: captureTeardownSurfaces(in: workspaces)) + } + + private func terminalSurfaces(in id: UUID) -> [TerminalSurface] { + var workspaces = appDelegate.tabManagerFor(windowId: id)?.tabs ?? [] + if id == windowId, !workspaces.contains(where: { $0 === workspace }) { + workspaces.append(workspace) + } + return captureTeardownSurfaces(in: workspaces) + } + + func captureTeardownSurfaces(in workspaces: [Workspace]) -> [TerminalSurface] { + var seen: Set = [] + return (initialSurfaces + workspaces.flatMap { workspace in + workspace.panels.values.compactMap { panel -> TerminalSurface? in + (panel as? TerminalPanel)?.surface + } + }).filter { surface in + seen.insert(ObjectIdentifier(surface)).inserted + } + } + + func waitForTeardown(of surfaces: [TerminalSurface]) async { + for surface in surfaces { + #expect( + await surface.waitForCloseRuntimeTeardown(timeout: .seconds(10)), + "Timed out waiting for a terminal surface to finish native teardown" + ) + } + } } } diff --git a/scripts/verify-cmux-tui-hosted.sh b/scripts/verify-cmux-tui-hosted.sh index d0a563dcba84..ca5e07ace677 100755 --- a/scripts/verify-cmux-tui-hosted.sh +++ b/scripts/verify-cmux-tui-hosted.sh @@ -127,6 +127,7 @@ gh workflow run "$WORKFLOW" \ -f "commit=$commit" \ -f "mode=$mode" \ -f "test_filter=$test_filter" \ + -f "use_blacksmith_macos=true" \ -f "request_id=$request_id" run_id="" diff --git a/tests/test_tui_npm_package_artifact.py b/tests/test_tui_npm_package_artifact.py index aa4ae6ac3a1c..9078df812e80 100644 --- a/tests/test_tui_npm_package_artifact.py +++ b/tests/test_tui_npm_package_artifact.py @@ -84,12 +84,32 @@ def test_publish_workflows_restore_the_mode_preserving_archive() -> None: assert "--archive dist/npm-packages.tar.gz" in workflow +def test_build_workflow_executes_stamped_raw_and_packaged_artifacts() -> None: + build = (ROOT / ".github/workflows/cmux-tui-build-package.yml").read_text() + safe_build_env = ( + "PACKAGE_NPM: ${{ inputs.package_npm }}\n" + " VERSION: ${{ inputs.version }}" + ) + + assert build.count(safe_build_env) == 3 + assert build.count('CMUX_TUI_DISTRIBUTION_VERSION="$VERSION"') == 3 + assert build.count('CMUX_TUI_NPM_BOOTSTRAP_VERSION="$VERSION"') == 3 + assert 'CMUX_TUI_DISTRIBUTION_VERSION="${{ inputs.version }}"' not in build + assert 'if [[ "${{ inputs.package_npm }}" == "true" ]]; then' not in build + assert "EXPECTED_VERSION: ${{ inputs.version }}" in build + assert "(inputs.package_npm || inputs.package_pypi)" not in build + assert build.count("verify_artifact_identity.py") == 4 + assert "dist/npm-smoke/node_modules/cmux/bin/cmux.js" in build + assert "/tmp/cmux-tui-wheel-smoke/bin/cmux" in build + + def main() -> None: with tempfile.TemporaryDirectory() as directory: test_archive_round_trip_preserves_package_executables(Path(directory)) with tempfile.TemporaryDirectory() as directory: test_extract_rejects_paths_outside_package_root(Path(directory)) test_publish_workflows_restore_the_mode_preserving_archive() + test_build_workflow_executes_stamped_raw_and_packaged_artifacts() if __name__ == "__main__": diff --git a/tests/test_tui_package_launchers.py b/tests/test_tui_package_launchers.py new file mode 100644 index 000000000000..674b0a8e7669 --- /dev/null +++ b/tests/test_tui_package_launchers.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import os +import platform +import runpy +import shutil +import stat +import subprocess +import sys +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +VERIFY_SCRIPT = ROOT / "cmux-tui/dist/scripts/verify_artifact_identity.py" +NPM_LAUNCHER = ROOT / "cmux-tui/dist/npm/cmux/bin/cmux.js" +PYPI_PACKAGER = ROOT / "cmux-tui/dist/scripts/package_pypi.py" +EXPECTED_IDENTITY = "cmux 1.2.3 (build123; ghostty ghost456)" + +NPM_PACKAGE_BY_PLATFORM = { + ("darwin", "arm64"): "cmux-tui-darwin-arm64", + ("darwin", "x86_64"): "cmux-tui-darwin-x64", + ("linux", "x86_64"): "cmux-tui-linux-x64", + ("linux", "aarch64"): "cmux-tui-linux-arm64", +} + +RUST_TARGET_BY_PLATFORM = { + ("darwin", "arm64"): "aarch64-apple-darwin", + ("darwin", "x86_64"): "x86_64-apple-darwin", + ("linux", "x86_64"): "x86_64-unknown-linux-musl", + ("linux", "aarch64"): "aarch64-unknown-linux-musl", +} +PYPI_TARGETS = tuple(runpy.run_path(str(PYPI_PACKAGER))["TARGETS"]) +RUST_TARGETS = tuple(target.rust_target for target in PYPI_TARGETS) +WHEEL_TAG_BY_PLATFORM = { + platform_key: next( + target.platform_tags[0] + for target in PYPI_TARGETS + if target.rust_target == rust_target + ) + for platform_key, rust_target in RUST_TARGET_BY_PLATFORM.items() +} + + +def test_pypi_fixture_targets_match_packager() -> None: + assert tuple(RUST_TARGET_BY_PLATFORM.values()) == RUST_TARGETS + + +def test_pypi_fixture_wheel_tags_match_packager() -> None: + fixture_tags = tuple( + WHEEL_TAG_BY_PLATFORM[key] + for key in RUST_TARGET_BY_PLATFORM + ) + declared = tuple(target.platform_tags[0] for target in PYPI_TARGETS) + assert fixture_tags == declared + + +def normalized_platform() -> tuple[str, str]: + machine = platform.machine().lower() + if machine in {"amd64", "x64"}: + machine = "x86_64" + elif machine == "arm64": + machine = "arm64" + return sys.platform, machine + + +def write_executable(path: Path, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"#!/bin/sh\n{body}\n") + path.chmod(0o755) + + +def run( + command: list[str | Path], + *, + cwd: Path = ROOT, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(part) for part in command], + cwd=cwd, + env=env, + check=False, + capture_output=True, + text=True, + ) + + +def test_identity_verifier_executes_artifact_and_rejects_mismatch( + tmp_path: Path, +) -> None: + binary = tmp_path / "cmux-tui" + write_executable(binary, f"printf '%s\\n' '{EXPECTED_IDENTITY}'") + arguments = [ + sys.executable, + VERIFY_SCRIPT, + "--binary", + binary, + "--version", + "1.2.3", + "--build-commit", + "build123", + "--ghostty-commit", + "ghost456", + ] + + accepted = run(arguments) + assert accepted.returncode == 0, accepted.stderr + + rejected = run([*arguments[:-1], "wrong-ghostty"]) + assert rejected.returncode != 0 + assert "identity mismatch" in rejected.stderr + + +def test_npm_launcher_preserves_replayable_invocation(tmp_path: Path) -> None: + key = normalized_platform() + package = NPM_PACKAGE_BY_PLATFORM.get(key) + assert package is not None, f"unsupported test platform: {key}" + node = shutil.which("node") + assert node is not None, "node is required to exercise the npm launcher" + + launcher = tmp_path / "node_modules/cmux/bin/cmux.js" + launcher.parent.mkdir(parents=True) + shutil.copyfile(NPM_LAUNCHER, launcher) + launcher.chmod(0o755) + (launcher.parent.parent / "package.json").write_text( + '{"name":"cmux","version":"1.2.3"}\n' + ) + package_root = tmp_path / "node_modules" / package + package_root.mkdir(parents=True) + (package_root / "package.json").write_text( + f'{{"name":"{package}","version":"1.2.3"}}\n' + ) + write_executable( + package_root / "bin/cmux-tui", + "printf '%s\\n' \"$CMUX_TUI_LAUNCHER_COMMAND\"", + ) + + one_shot_env = os.environ.copy() + one_shot_env["npm_command"] = "exec" + one_shot = run([node, launcher], cwd=tmp_path, env=one_shot_env) + assert one_shot.returncode == 0, one_shot.stderr + assert one_shot.stdout.strip() == "npx cmux@1.2.3" + + installed_env = os.environ.copy() + installed_env.pop("npm_command", None) + installed = run([node, launcher], cwd=tmp_path, env=installed_env) + assert installed.returncode == 0, installed.stderr + assert installed.stdout.strip() == "cmux" + + +def test_pypi_launcher_preserves_uvx_invocation(tmp_path: Path) -> None: + key = normalized_platform() + wheel_tag = WHEEL_TAG_BY_PLATFORM.get(key) + assert wheel_tag is not None, f"unsupported test platform: {key}" + + binaries = tmp_path / "binaries" + for target in RUST_TARGETS: + write_executable( + binaries / f"cmux-tui-{target}", + "printf '%s\\n' \"$CMUX_TUI_LAUNCHER_COMMAND\"", + ) + write_executable( + binaries / f"cmux-tui-hook-{target}", + "exit 0", + ) + + wheels = tmp_path / "wheels" + packaged = run( + [ + sys.executable, + PYPI_PACKAGER, + "--binaries-dir", + binaries, + "--version", + "1.2.3", + "--out", + wheels, + ] + ) + assert packaged.returncode == 0, packaged.stderr + + wheel = wheels / f"cmux-1.2.3-py3-none-{wheel_tag}.whl" + installed = tmp_path / "installed" + with zipfile.ZipFile(wheel) as archive: + hook = archive.getinfo("cmux_tui/bin/cmux-tui-hook") + assert hook.external_attr >> 16 & stat.S_IXUSR + archive.extractall(installed) + assert (installed / "cmux_tui/bin/cmux-tui-hook").is_file() + (installed / "cmux_tui/bin/cmux-tui").chmod(0o755) + + environment = os.environ.copy() + environment["PYTHONPATH"] = str(installed) + environment["CMUX_TUI_LAUNCHER_COMMAND"] = "uvx cmux==0.9.0" + launched = run( + [ + sys.executable, + "-c", + ( + "import sys; " + "sys.argv = ['/tmp/uv/archive-v0/hash/bin/cmux']; " + "from cmux_tui._main import main; " + "main()" + ), + ], + env=environment, + ) + assert launched.returncode == 0, launched.stderr + assert launched.stdout.strip() == "uvx cmux==1.2.3" + + +def main() -> None: + import tempfile + + test_pypi_fixture_targets_match_packager() + test_pypi_fixture_wheel_tags_match_packager() + with tempfile.TemporaryDirectory() as directory: + test_identity_verifier_executes_artifact_and_rejects_mismatch( + Path(directory) + ) + with tempfile.TemporaryDirectory() as directory: + test_npm_launcher_preserves_replayable_invocation(Path(directory)) + with tempfile.TemporaryDirectory() as directory: + test_pypi_launcher_preserves_uvx_invocation(Path(directory)) + + +if __name__ == "__main__": + main()