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
27 changes: 26 additions & 1 deletion .claude/rules/control-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ renumbering. Do not reintroduce a count anywhere.
`.fullscreen`, `.minimize`
- `keymap.reload`, `keymap.list`, `config.reload`, `theme.set`, `theme.list`, `restore.capture`,
`restore.clear`, `restore.mode`, `version`
- `zmx.list`, `zmx.prune`, `zmx.kill`, `zmx.tree`, `zmx.attach`
- `zmx.list`, `zmx.prune`, `zmx.kill`, `zmx.reset`, `zmx.tree`, `zmx.attach`

`debug.appearance` is a private `Command` case, absent from the list above, used only by `AppearanceFlipUITests`.
It accepts light/dark, sets `NSApp.appearance`, posts `.agtermSystemAppearanceChanged`, echoes the effective
Expand Down Expand Up @@ -924,6 +924,31 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi
already gone. The suppression is gated on `backedByZmx`: a requested-live launch that fell back keeps its
claimed daemons while each pane runs a plain shell, so an ungated kill would close a pane that never
attached to what it destroyed.
- `zmx.reset` is Help ▸ Reset Live Sessions… without the dialog, and both run `LiveResetCoordinator`.
The dispatcher refuses without `--force` before the host; the coordinator then refuses, in order, when
Live is not both the configured and the launched mode, when the listing failed, when the claim walk is
incomplete or claims a pane twice, and when no pane is orphaned or app-attributed.
`LiveReset.select` in agtermCore joins `paneClaims()` to the listing; the dialog counts distinct sessions
and the reply carries `result.liveReset` (sessions, panes, pending) plus the dialog body as `text`.
The connection thread quits only after it has written the reply to THAT request, decided from the
request being `zmx.reset` and the response being ok, never from shared state: remote workers write
other replies in parallel and must not quit the app. A reply that could not be written leaves the reset
pending for the menu or a later request.
The quit writes `live-reset.json` in the state directory only after the exit capture ran and the
checked snapshot save succeeded, then spawns the relauncher; a relauncher that cannot start removes the
marker. The next launch consumes the marker before any kill and only NARROWS it: a target is killed when
it is still claimed, still listed with the same leader pid and still orphaned; gone restores normally;
anything else is skipped. Every selected leader is polled whatever the batched kill reported, and a
survivor's pane gets neither its replay nor its durable command at that launch.
A confirmed reset arms and skips the quit alert only while Live is still both modes
(`armablePending`): a mode change after confirmation leaves the next launch unable to suppress a
survivor's ordinary seed. A launch that did not get Live discards a marker it finds without killing.
The listing and the batched kill are clamped to the remaining budget, and a batch that cannot start
before the budget expires leaves every selected pane suppressed. The Help item shows a refusal in user
words through `presentRefusal`; only a cancel is silent.
Read-back is `liveReset` on the tree top level and the `zmx list` header, omitted when nothing is
pending and no launch consumed a marker. XCUITest exemption: the command quits the app, so its
coverage is hosted and package tests plus the isolated acceptance run, like `restore.mode`.

## Remote sessions

Expand Down
6 changes: 6 additions & 0 deletions .claude/rules/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ session drag are out of scope.
The reason is an attribute, not a param, despite `AERegistry.h` calling it a parameter: loginwindow
writes it with `AEPutAttributePtr`. Never switch that read to `paramDescriptor`.
The GUI-only prompt is keep-in-sync exempt and manually verified.
- A confirmed Live sessions reset (`LiveResetCoordinator.pending`) skips the quit alert, since its own
dialog or `zmx.reset --force` was the confirmation. `AppDelegate.exitFlush` fixes the order: capture,
finalize pending closes, then the CHECKED save; only a fully saved snapshot arms the marker and the
relauncher. `LaunchOrchestration.run` in `LiveResetConsumer.swift` owns the launch side: the library's
inventory sink only stores the inventory, the consumer runs after `WindowLibrary` returns, and the
ordinary reap and the foreground resolver refresh follow it, all before any window mounts.
- App-side `WindowRegistry` maps IDs to `NSWindow`. Register/unregister through `TitleProbeView`;
`raise` deminiaturizes and fronts, and `close` uses `performClose` so standard teardown runs.

Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ C-boundary concurrency before changing the bridge.
teardown, and no SIGHUP reaches the process because the pty's session leader is the surviving `login`, so
it outlives the app in whatever loop it was in. `hud.sh` takes the app's pid through its input file and
exits on a builtin `kill -0`.
- A confirmed Live sessions reset (Help item or `zmx.reset`) is the one path that ends CLAIMED daemons at a
Live launch: `LiveResetConsumer` consumes the marker before any kill and only narrows it, then the
ordinary reap runs. Nothing arms it but the dialog or an explicit `--force` request; `control-api.md`
owns the contract.
- Live-session reap follows the requested restore mode. A requested-live launch preserves claimed daemons
when eligibility falls back to fresh shells; a deliberate Fresh shells or Re-run commands launch reaps
every detached app daemon in the state directory. Semantic deletion kills the named daemon, while app and
Expand Down
73 changes: 69 additions & 4 deletions agterm/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import agtermCore
import AppKit
import os

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
typealias ForegroundCommandReader = (GhosttySurfaceView, String?, ZmxForegroundResolver.Snapshot?) -> [String]?
typealias ExitCapture = @MainActor @Sendable ([Session]) -> Int

private static let logger = Logger(subsystem: "com.umputun.agterm", category: "AppDelegate")

// Leaves 150 ms after the refresh's 350 ms worst case for the per-pane kernel reads.
private static let exitCaptureBudget: Duration = .milliseconds(500)

Expand All @@ -27,6 +30,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// Injected exit policy; the configured mode is evaluated when the exit happens.
var captureOnExit: ExitCapture?

/// The one-shot marker store for a confirmed Live sessions reset, in the state directory; set on scene
/// appear. Nil leaves a pending reset unarmed, and the quit proceeds as an ordinary quit.
var liveResetMarkerStore: LiveResetMarkerStore?

/// Holds the confirmed reset between the dialog or `zmx.reset` and the quit; set on scene appear.
var liveReset: LiveResetCoordinator?

/// Strongly retains the current Dock menu's target objects so nil-sender dispatch never depends on
/// AppKit's target lifetime; replaced whenever the Dock asks for a fresh menu.
var dockMenuActionTargets: [DockMenuActionTarget] = []
Expand Down Expand Up @@ -303,6 +313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply {
guard !ContentView.isUITestLaunch, let library else { return .terminateNow }
if QuitReason.isSystemQuit(NSAppleEventManager.shared().currentAppleEvent) { return .terminateNow }
if liveReset?.armablePending != nil { return .terminateNow }
let counts = library.openCounts()
guard counts.windows > 0 else { return .terminateNow }
let alert = NSAlert()
Expand All @@ -327,16 +338,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// mark terminating so per-window willClose can't zero the open-set during quit — it must survive
// for the next launch's reopen-all.
library?.isTerminating = true
if let library { _ = captureOnExit?(library.allOpenSessions()) }
library?.finalizeAllPendingCloses()
// flush the stores + index: cwd changes since the last structural mutation aren't auto-persisted.
library?.saveAllOpen()
if let library {
// flush the stores + index: cwd changes since the last structural mutation aren't auto-persisted.
Self.exitFlush(pending: liveReset?.armablePending, steps: ExitFlushSteps(
capture: { _ = self.captureOnExit?(library.allOpenSessions()) },
finalize: { library.finalizeAllPendingCloses() },
saveChecked: { library.saveAllOpenChecked() },
save: { library.saveAllOpen() },
arm: { selection in
guard let store = self.liveResetMarkerStore else { return false }
return Self.armLiveReset(selection, store: store) {
LiveResetRelauncher().spawn(pid: getpid(), bundle: Bundle.main.bundleURL,
stateDirectory: ProcessInfo.processInfo.environment["AGTERM_STATE_DIR"])
}
}))
}
library?.saveIndex()
// flush pending debounced settings writes (a keyboard-driven opacity/blur change holds a ~0.3s save
// no drag-end commit fires) so they survive ⌘Q.
settingsModel?.flushPendingSaves()
}

struct ExitFlushSteps {
let capture: () -> Void
let finalize: () -> Void
let saveChecked: () -> Bool
let save: () -> Void
let arm: (LiveReset.Selection) -> Bool
}

/// The exit flush in its fixed order: capture, finalize pending closes, then save. A pending Live
/// sessions reset takes the CHECKED save and arms only when it reports every snapshot written; capture
/// is invoked, not judged, since its count is best effort. Returns whether a reset was armed.
@discardableResult
static func exitFlush(pending: LiveReset.Selection?, steps: ExitFlushSteps) -> Bool {
steps.capture()
steps.finalize()
guard let pending else {
steps.save()
return false
}
guard steps.saveChecked() else {
logger.error("live sessions reset not armed: a window snapshot did not save")
return false
}
return steps.arm(pending)
}

/// Writes the marker, then spawns the relauncher; a relauncher that cannot start takes the marker with
/// it, so a reset is never armed for a launch nobody triggers.
static func armLiveReset(_ selection: LiveReset.Selection, store: LiveResetMarkerStore, spawn: () -> Bool) -> Bool {
do {
try store.write(LiveReset.Marker(targets: selection.targets))
} catch {
logger.error("live sessions reset not armed: marker write failed: \(String(describing: error), privacy: .public)")
return false
}
guard spawn() else {
store.remove()
logger.error("live sessions reset not armed: the relauncher did not start")
return false
}
return true
}

/// Keep the exit policy live so a mode selected after launch governs the next launch.
static func makeExitCapture(settingsModel: SettingsModel,
zmxResolver: ZmxForegroundResolver?) -> ExitCapture {
Expand Down
63 changes: 59 additions & 4 deletions agterm/Control/ControlServer+Zmx.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,54 @@ extension ControlServer {
probes[pid] = result
return result
}
let candidate = zmxClient.flatMap { liveAttributionProbe.hostPID($0.endpoint) }
let host = candidate.flatMap { responsible($0) == .live($0) ? $0 : nil }
let host = liveHostPID(responsible: responsible)
return Dictionary(uniqueKeysWithValues: identities.map { identity in
let leader = leaders[ZmxSupport.daemonName(for: identity)]
return (identity, SessionHost.classify(leader: leader, responsible: leader.map(responsible), hostPid: host, appPid: liveAttributionProbe.appPID))
})
}

/// The session host's pid when its pidfile names a live host, else nil.
private func liveHostPID(responsible: (pid_t) -> SessionHost.ResponsibleProcess) -> pid_t? {
guard let candidate = zmxClient.flatMap({ liveAttributionProbe.hostPID($0.endpoint) }) else { return nil }
return responsible(candidate) == .live(candidate) ? candidate : nil
}

/// The reset's read-back for the tree top level and the `zmx list` header: nil when nothing is pending
/// and no launch consumed a marker, so an untouched instance shows no field at all.
func liveResetReadback() -> ControlLiveResetReadback? {
let pending = liveReset?.pending.map(\.targets.count)
let last = liveResetOutcome()
guard pending != nil || last != nil else { return nil }
return ControlLiveResetReadback(pending: pending, last: last)
}

/// `zmx.reset`: the dialog's confirm path without the dialog. The quit is not requested here; the
/// connection thread requests it once this reply is written.
func resetLiveSessions() -> ControlResponse {
guard let liveReset else {
return ControlResponse(ok: false, error: ControlActionsUnsupported.message("zmx.reset"))
}
switch liveReset.request(confirmed: true) {
case .refused(let refusal):
return ControlResponse(ok: false, error: refusal.message)
case .cancelled:
return ControlResponse(ok: false, error: "zmx.reset was cancelled")
case .confirmed(let selection):
let status = ControlLiveResetStatus(sessions: selection.sessionCount, panes: selection.targets.count, pending: true)
return ControlResponse(ok: true, result: ControlResult(text: LiveReset.dialogText(sessionCount: selection.sessionCount).body,
liveReset: status))
}
}

/// The panes Help ▸ Reset Live Sessions… would reset: every claim, open or saved, whose daemon leader
/// is orphaned or attributed to this app. Nil when the listing failed, which refuses the action.
func liveResetSelection() -> LiveReset.Selection? {
guard let zmxClient, let records = zmxClient.sessionRecords() else { return nil }
return LiveReset.select(claims: library.paneClaims(), records: records,
classify: liveAttributionProbe.classifier(endpoint: zmxClient.endpoint))
}

/// Observed daemons joined against the panes that claim them, with the restore status as a header.
///
/// A failed listing is an error rather than an empty inventory: an empty namespace is a real answer and
Expand All @@ -45,7 +85,7 @@ extension ControlServer {
let result = ZmxInventory.join(observed: observed, claims: walk.claims,
inventoryComplete: walk.complete)
let inventory = ControlZmxInventory(restore: restoreStatus(), result: result,
endpoint: client.endpoint)
endpoint: client.endpoint, liveReset: liveResetReadback())
return ControlResponse(ok: true, result: ControlResult(zmx: inventory))
}
}
Expand Down Expand Up @@ -103,7 +143,7 @@ extension ControlServer {
result: ZmxInventory.join(observed: observed,
claims: walk.claims,
inventoryComplete: walk.complete),
endpoint: client.endpoint)
endpoint: client.endpoint, liveReset: liveResetReadback())
// a live store IS the open-window test, the same one `openCounts` uses: a closed window has no
// store, and its panes are not attachable from here anyway
let windows = library.windows.compactMap { entry in
Expand Down Expand Up @@ -361,6 +401,21 @@ struct LiveAttributionProbe {
var hostPID: (ControlZmxEndpoint) -> pid_t? = LiveAttributionProbe.host
var appPID: pid_t = getpid()

/// A classifier over daemon leaders that resolves the host once and probes each pid once.
func classifier(endpoint: ControlZmxEndpoint) -> (String, Int32) -> SessionHost.Attribution {
var probes: [pid_t: SessionHost.ResponsibleProcess] = [:]
func probed(_ pid: pid_t) -> SessionHost.ResponsibleProcess {
if let cached = probes[pid] { return cached }
let result = responsible(pid)
probes[pid] = result
return result
}
let host = hostPID(endpoint).flatMap { probed($0) == .live($0) ? $0 : nil }
return { _, leader in
SessionHost.classify(leader: leader, responsible: probed(leader), hostPid: host, appPid: appPID)
}
}

private static func lookup(_ leader: pid_t) -> SessionHost.ResponsibleProcess {
guard Responsibility.system.isAvailable, let pid = Responsibility.system.responsibleProcess(of: leader) else { return .unknown }
if kill(pid, 0) == 0 || errno == EPERM { return .live(pid) }
Expand Down
Loading
Loading