Skip to content

Commit 103cc2e

Browse files
committed
Fix zmx kill racing tab teardown on Close Tab.
Keep a strong reference through the async kill, snapshot the live tsshd session up front, and wrap `zmx kill --force` in `sh -c` so the probe actually runs.
1 parent d438ff4 commit 103cc2e

3 files changed

Lines changed: 16 additions & 7 deletions

File tree

‎rootshell/Features/Multiplexer/MuxSessionDetach.swift‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,17 @@ enum MuxSessionDetach {
294294
return
295295
}
296296

297+
// Snapshot the live transport before clearing bindings / any close
298+
// side effects — closeTab may already be removing us from the model.
299+
let trzsz = terminal.session as? TrzszSession
300+
let connectionConfig = terminal.connectionConfig
301+
297302
// Drop the binding so a concurrent detach/close path cannot double-kill.
298303
terminal.passthroughMultiplexer = nil
299304
AgentAttentionCenter.shared.topologyDidChange()
300305

301306
// 1) Live tsshd probe — same connection, no re-auth.
302-
if let trzsz = terminal.session as? TrzszSession {
307+
if let trzsz {
303308
do {
304309
_ = try await trzsz.runProbeCommand(command)
305310
Ghostty.logger.info("zmx kill via tsshd probe succeeded for \(name, privacy: .public)")
@@ -311,7 +316,6 @@ enum MuxSessionDetach {
311316
}
312317

313318
// 2) Headless SSH fallback (new connection — may prompt the agent).
314-
let connectionConfig = terminal.connectionConfig
315319
if let ssh = connectionConfig.sshConfigForHistory
316320
?? connectionConfig.underlyingSSHConfig {
317321
do {

‎rootshell/Features/SSH/Config/SSHConfig.swift‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -804,11 +804,12 @@ struct SSHConfig: Codable, Hashable {
804804

805805
/// Best-effort destroy for an explicit tab/split close. Names must already
806806
/// pass ``isEmbeddableZmxSessionName`` (no shell quoting). `--force` kills
807-
/// the session even while this client is still attached.
807+
/// the session even while this client is still attached. Wrapped in
808+
/// `sh -c` so PATH prefix / `&&` work under tsshd `runCommand` and SSH exec.
808809
static func zmxKillCommandLine(sessionName: String) -> String? {
809810
guard isEmbeddableZmxSessionName(sessionName) else { return nil }
810-
return "\(remoteExecPathPrefix)command -v zmx >/dev/null 2>&1"
811-
+ " && ZMX_SESSION_PREFIX= zmx kill --force \(sessionName)"
811+
return "sh -c '\(remoteExecPathPrefix)command -v zmx >/dev/null 2>&1"
812+
+ " && ZMX_SESSION_PREFIX= exec zmx kill --force \(sessionName)'"
812813
}
813814

814815
/// Shared zmx exec command used by all session types.

‎rootshell/UI/Terminal/TerminalView.swift‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,9 +1558,13 @@ extension Ghostty {
15581558
// remote session (`zmx kill`) BEFORE tearing down the client —
15591559
// closing the client alone is zmx’s detach path. Prefer an
15601560
// in-band tsshd probe so agent keys do not need a second approval.
1561+
//
1562+
// Strongly capture `self`: closeTab removes the pane from the
1563+
// model immediately after cleanup returns, and a `[weak self]`
1564+
// Task would often see nil and skip both kill and teardown —
1565+
// leaving the zmx session alive (looks like detach).
15611566
if reason == .userClose, MuxSessionDetach.hasZmxSessionToDestroy(on: self) {
1562-
Task { @MainActor [weak self] in
1563-
guard let self else { return }
1567+
Task { @MainActor in
15641568
await MuxSessionDetach.destroyZmxSessionIfNeeded(on: self)
15651569
self.completeCleanupAfterSessionStop(reason: reason)
15661570
}

0 commit comments

Comments
 (0)