Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ public final class AppState {
/// not brand-new terminals created via the `new-terminal` RPC.
public var autoLaunchTerminals: Set<UUID> = []

/// Pending agent launch command per terminal ID, for brand-new managed
/// terminals created via `new-terminal --command`. The command is NOT
/// pasted immediately (that races the shell's line editor — issue #408);
/// it is held here until the readiness sentinel fires `.shellReady`, at
/// which point `SessionService.wireTerminalReadiness` pastes it. In-memory
/// only — never persisted, so a relaunch can't re-paste a stale command.
public var pendingLaunchCommands: [UUID: String] = [:]

// MARK: - Hook Events (per-session Observable wrappers)

/// Per-session hook state. Using @Observable class wrappers so mutations to one
Expand Down
56 changes: 54 additions & 2 deletions Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ public final class TmuxBackend {
name: String,
cwd: String,
command: String?,
trackReadiness: Bool
trackReadiness: Bool,
newWindowTimeout: TimeInterval = TmuxController.defaultTimeout
) throws -> TmuxBinding {
precondition(!tmuxBinary.isEmpty, "TmuxBackend.configure(...) must be called first")
let ctrl: TmuxController
Expand Down Expand Up @@ -219,7 +220,8 @@ public final class TmuxBackend {
name: name,
cwd: cwd.isEmpty ? nil : cwd,
env: env,
command: wrapperPath
command: wrapperPath,
timeout: newWindowTimeout
)
bindings[id] = windowIndex

Expand Down Expand Up @@ -365,6 +367,56 @@ public final class TmuxBackend {
}
}

/// Bare login shells we consider "orphaned" when a cockpit window is not
/// referenced by any terminal — i.e. a window left at a shell with no agent
/// running (#408). Anything else (claude/codex/node/an editor/…) is left
/// alone. tmux reports `pane_current_command` without the login-shell `-`
/// prefix, but we match both forms defensively.
nonisolated static let orphanLoginShells: Set<String> = [
"zsh", "-zsh", "bash", "-bash", "sh", "-sh",
"fish", "-fish", "dash", "-dash", "ksh", "tcsh", "csh", "login",
]

/// Decide whether a single cockpit window should be reaped. Pure so the
/// policy is unit-testable: reap only when the window is NOT referenced by a
/// live terminal AND its pane is sitting at a bare login shell. Never reaps
/// a window running an agent (or any non-shell process), so an agent that
/// exited and left the user at a shell — but whose terminal still references
/// the window — is preserved (it's in `keep`).
nonisolated static func shouldReapWindow(index: Int, command: String, keep: Set<Int>) -> Bool {
if keep.contains(index) { return false }
return orphanLoginShells.contains(command)
}

/// Reap cockpit windows that no live terminal references AND that are
/// sitting at a bare login shell — leaked windows from a timed-out
/// `new-window` or a forgotten terminal (#408). `keepWindowIndices` is the
/// set of window indices referenced by persisted terminals; it is unioned
/// with the in-memory `bindings` so a window created/adopted this run is
/// never reaped. Best-effort; returns the count reaped.
@discardableResult
public func reapUnboundCockpitWindows(keepWindowIndices: Set<Int>) -> Int {
guard let ctrl = controller else { return 0 }
let keep = keepWindowIndices.union(bindings.values)
let windows: [(index: Int, command: String)]
do {
windows = try ctrl.listWindowCommands()
} catch {
reportIfTimeout(error)
return 0
}
var reaped = 0
for window in windows where Self.shouldReapWindow(index: window.index, command: window.command, keep: keep) {
ctrl.killWindow(index: window.index)
NSLog("[CrowTelemetry tmux:orphan_window_reaped index=\(window.index) command=\(window.command)]")
reaped += 1
}
if reaped > 0 {
NSLog("[Crow] Reaped \(reaped) orphaned bare-shell cockpit window(s) (#408)")
}
return reaped
}

/// Return the shared cockpit Ghostty surface, lazily creating it the
/// first time. The surface attaches to the live tmux session via
/// `tmux -S … attach-session -t …` as its child command.
Expand Down
25 changes: 23 additions & 2 deletions Packages/CrowTerminal/Sources/CrowTerminal/TmuxController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,34 @@ public struct TmuxController: Sendable {
return out.split(separator: "\n").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
}

/// List each window's index and the command currently running in its
/// active pane (`#{window_index}` + `#{pane_current_command}`). Used by the
/// orphan-window reaper to distinguish a window running an agent from a
/// bare login shell (#408).
public func listWindowCommands() throws -> [(index: Int, command: String)] {
let out = try run(["list-windows", "-t", sessionName,
"-F", "#{window_index}\t#{pane_current_command}"])
return out.split(separator: "\n").compactMap { line in
let parts = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2, let idx = Int(parts[0].trimmingCharacters(in: .whitespaces)) else {
return nil
}
return (idx, parts[1].trimmingCharacters(in: .whitespaces))
}
}

// MARK: - Windows

/// `timeout` defaults to the per-call default. Callers spawning a window
/// while the app is under load (many concurrent hydrations, a contended
/// main actor) can pass a longer budget so a slow `new-window` doesn't
/// SIGTERM and leave the terminal window-less (issue #408).
public func newWindow(
name: String? = nil,
cwd: String? = nil,
env: [String: String] = [:],
command: String? = nil
command: String? = nil,
timeout: TimeInterval = TmuxController.defaultTimeout
) throws -> Int {
var args = ["new-window", "-P", "-F", "#{window_index}", "-t", sessionName]
if let name { args.append(contentsOf: ["-n", name]) }
Expand All @@ -122,7 +143,7 @@ public struct TmuxController: Sendable {
if let cwd, !cwd.isEmpty { args.append(contentsOf: ["-c", cwd]) }
for (k, v) in env { args.append(contentsOf: ["-e", "\(k)=\(v)"]) }
if let command { args.append(command) }
let out = try run(args)
let out = try run(args, timeout: timeout)
guard let idx = Int(out.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw TmuxError.cliFailed(
args: args,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation
import Testing
@testable import CrowTerminal

/// Policy tests for the conservative orphan-window reaper (#408): reap a
/// cockpit window only when NO live terminal references it AND it is sitting at
/// a bare login shell. Pure over `shouldReapWindow`, so no tmux needed.
@Suite("Orphan cockpit window reaper policy (#408)")
struct OrphanWindowReaperTests {

@Test func reapsUnboundBareShell() {
#expect(TmuxBackend.shouldReapWindow(index: 3, command: "zsh", keep: []))
#expect(TmuxBackend.shouldReapWindow(index: 3, command: "-zsh", keep: []))
#expect(TmuxBackend.shouldReapWindow(index: 3, command: "bash", keep: []))
#expect(TmuxBackend.shouldReapWindow(index: 3, command: "sh", keep: []))
}

@Test func keepsBoundWindowEvenWhenBareShell() {
// A terminal references it (e.g. the agent exited and the user is now at
// the shell) — must be preserved.
#expect(!TmuxBackend.shouldReapWindow(index: 3, command: "zsh", keep: [3]))
}

@Test func keepsWindowRunningAProcess() {
// Anything that isn't a bare login shell is left alone.
#expect(!TmuxBackend.shouldReapWindow(index: 4, command: "claude", keep: []))
#expect(!TmuxBackend.shouldReapWindow(index: 4, command: "node", keep: []))
#expect(!TmuxBackend.shouldReapWindow(index: 4, command: "codex", keep: []))
#expect(!TmuxBackend.shouldReapWindow(index: 4, command: "tail", keep: [])) // session anchor
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import Testing
/// covered separately by the visual demo path. Skipped automatically
/// when no tmux binary is present on the host.
@MainActor
@Suite("TmuxBackend integration", .enabled(if: discoveredTmuxBinary != nil))
// Serialized: these are real-tmux integration tests that spawn login shells and
// assert on readiness *timing* (e.g. a 0.15s watch budget). Run in parallel they
// contend for CPU during shell startup and the tight-timing cases flake — most
// visibly once the #408 concurrent-launch test spins up several shells at once.
@Suite("TmuxBackend integration", .enabled(if: discoveredTmuxBinary != nil), .serialized)
struct TmuxBackendTests {

private func makeBackend() -> TmuxBackend {
Expand Down Expand Up @@ -373,4 +377,75 @@ struct TmuxBackendTests {
try await Task.sleep(nanoseconds: 100_000_000)
#expect(received.isEmpty)
}

// MARK: - #408 concurrent managed launch: no bare-zsh orphans

/// The #408 repro. Spawn several managed terminals at once, each deferring
/// its launch until `.shellReady` (registered with `command: nil`, then the
/// command pasted on the readiness fire — exactly what the new-terminal RPC
/// now does). Every window must reach readiness and run the pasted process;
/// none may be left at a bare login shell.
@Test func concurrentManagedLaunchesAllReachReadyNoBareShells() async throws {
let backend = makeBackend()
defer { backend.shutdown() }

let n = 6
let ids = (0..<n).map { _ in UUID() }
var windowIndexByID: [UUID: Int] = [:]
var ready = Set<UUID>()
var timedOut = Set<UUID>()

backend.onReadinessChanged = { id, state in
switch state {
case .shellReady:
guard !ready.contains(id) else { return }
ready.insert(id)
// The deferred paste: `exec` a marker process so the pane is no
// longer a bare login shell — proof the launch landed in a live
// shell rather than being dropped (the #408 failure).
try? backend.sendText(id: id, text: "exec sleep 97\n")
case .timedOut:
timedOut.insert(id)
default:
break
}
}

for id in ids {
let binding = try backend.registerTerminal(
id: id, name: "managed-\(id.uuidString.prefix(4))",
cwd: NSHomeDirectory(), command: nil, trackReadiness: true
)
windowIndexByID[id] = binding.windowIndex
}

// Wait (bounded) for all to report `.shellReady`. Shell startup is
// CPU-contended with N concurrent wrappers; 30s mirrors the backend's
// default readiness budget.
let deadline = Date().addingTimeInterval(30)
while ready.count < n && timedOut.isEmpty && Date() < deadline {
try await Task.sleep(nanoseconds: 100_000_000)
}
#expect(timedOut.isEmpty)
#expect(ready.count == n)

// Assert no managed window is left at a bare login shell. Poll briefly:
// the exec'd markers replace their shells asynchronously.
let ctrl = TmuxController(
tmuxBinary: discoveredTmuxBinary!, socketPath: backend.socketPath,
sessionName: TmuxBackend.cockpitSessionName
)
let ourIndices = Set(windowIndexByID.values)
var bareShells: [Int] = []
let checkDeadline = Date().addingTimeInterval(5)
repeat {
try await Task.sleep(nanoseconds: 200_000_000)
let windows = try ctrl.listWindowCommands().filter { ourIndices.contains($0.index) }
bareShells = windows
.filter { TmuxBackend.orphanLoginShells.contains($0.command) }
.map { $0.index }
} while !bareShells.isEmpty && Date() < checkDeadline

#expect(bareShells.isEmpty)
}
}
Loading
Loading