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
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,6 @@ extension SettingsFileStorage {
}
}

nonisolated enum SettingsFileStorageError: Error {
case missing
}

nonisolated final class InMemorySettingsFileStorage: @unchecked Sendable {
private let lock = NSLock()
private var dataByURL: [URL: Data] = [:]
Expand All @@ -70,7 +66,9 @@ nonisolated final class InMemorySettingsFileStorage: @unchecked Sendable {
lock.lock()
defer { lock.unlock() }
guard let data = dataByURL[url] else {
throw SettingsFileStorageError.missing
// Mirror real-disk semantics so callers that distinguish "file absent"
// (fresh start) from a read failure see the same `fileReadNoSuchFile`.
throw CocoaError(.fileReadNoSuchFile)
}
return data
}
Expand Down
8 changes: 8 additions & 0 deletions SupacodeSettingsShared/Support/SupaLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@ public nonisolated struct SupaLogger: Sendable {
logger.warning("\(message, privacy: .public)")
#endif
}

public func error(_ message: String) {
#if DEBUG
print("[\(category)] \(message)")
#else
logger.error("\(message, privacy: .public)")
#endif
}
}
14 changes: 12 additions & 2 deletions supacode/App/supacodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,13 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate {
private var bufferedDeeplinkURLs: [URL] = []

func applicationWillTerminate(_ notification: Notification) {
// Embed agent records so badges survive relaunch (agents only emit
// session_start once per process lifetime).
// Drop the queued debounce timers; an already-started async flush has no
// cancellation checkpoint and still completes, but the writer's lock plus the
// atomic temp+rename keep this terminal write from tearing. The on-quit save
// embeds agent records so badges survive relaunch (agents only emit
// session_start once per process lifetime), and a second concurrent instance
// overwriting the file is an accepted dev-only last-writer-wins window.
terminalManager?.cancelPendingLayoutSaves()
let agentsBySurface = appStore?.state.agentPresence.agentsBySurface() ?? [:]
terminalManager?.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface)
}
Expand Down Expand Up @@ -168,6 +173,11 @@ struct SupacodeApp: App {
_store = State(initialValue: appStore)
appDelegate.appStore = appStore
appDelegate.terminalManager = terminalManager
// Source live agent badge records for incremental layout captures; the [:]
// default would clobber badges that share a surface key on every save.
terminalManager.currentAgentsBySurface = { [weak appStore] in
appStore?.state.agentPresence.agentsBySurface() ?? [:]
}
Self.configureSocketHandlers(terminalManager: terminalManager, store: appStore)
}

Expand Down
62 changes: 50 additions & 12 deletions supacode/Clients/Zmx/ZmxClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ struct ZmxClient: Sendable {
/// Tear down a session. No-op on missing. Bounded by a 5-second timeout so a
/// stuck daemon can't hold the close path indefinitely.
var killSession: @Sendable (_ sessionID: String) async -> Void
/// Returns all live Supacode session names (`supa-<uuid>`) the daemon currently
/// hosts. Empty when zmx is unbundled or the daemon is unreachable. Used at
/// launch to reap sessions whose owning surface no longer exists.
var listSessions: @Sendable () async -> [String]
/// Returns each live Supacode session with its attached-client count, or nil
/// when the probe failed/timed out. nil means UNKNOWN (never reap); `[]` means
/// a successful empty listing. A `clients` of nil marks a session whose count
/// is unknown (err/status line), which the reaper must also spare.
var listSessionsWithClients: @Sendable () async -> [ZmxSessionListParser.Entry]?
}

/// Cached probe result so we log the bypass reason exactly once per process
Expand Down Expand Up @@ -202,13 +203,11 @@ extension ZmxClient {
killSession: { sessionID in
_ = await runZmx(["kill", sessionID])
},
listSessions: {
guard let stdout = await runZmx(["ls", "--short"], captureStdout: true) else { return [] }
return
stdout
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { $0.hasPrefix(ZmxSessionID.prefix) && !$0.isEmpty }
listSessionsWithClients: {
// nil from runZmx is the UNKNOWN signal (spawn error / timeout / non-zero
// exit); preserve it so the reaper never kills against a failed probe.
guard let stdout = await runZmx(["ls"], captureStdout: true) else { return nil }
return ZmxSessionListParser.parse(stdout)
}
)
}()
Expand All @@ -218,7 +217,7 @@ extension ZmxClient {
isBundled: { false },
wrapCommand: { _, _ in nil },
killSession: { _ in },
listSessions: { [] }
listSessionsWithClients: { [] }
)
}

Expand All @@ -234,6 +233,45 @@ extension DependencyValues {
}
}

/// Pure parser for zmx's full (`ls`, non-`--short`) tab-delimited listing.
/// Each line is `[→ | ]name=<name>\tk=v\t...`; a healthy session carries
/// `clients=<n>`, an unreachable one carries `err=`/`status=` (no count).
nonisolated enum ZmxSessionListParser {
struct Entry: Equatable, Sendable {
var name: String
/// nil when the count is unknown (err/status line); the reaper spares these.
var clients: Int?
}

static func parse(_ stdout: String) -> [Entry] {
stdout
.split(whereSeparator: \.isNewline)
.compactMap { line -> Entry? in
// Strip the current-session arrow / leading indent before tokenizing.
var trimmed = Substring(line)
if trimmed.hasPrefix("→ ") {
trimmed = trimmed.dropFirst(2)
}
// Non-current sessions are indented with a literal leading space run.
while trimmed.first?.isWhitespace == true {
trimmed = trimmed.dropFirst()
}
let fields = trimmed.split(separator: "\t")
var values: [Substring: Substring] = [:]
for field in fields {
guard let separator = field.firstIndex(of: "=") else { continue }
let key = field[field.startIndex..<separator]
let value = field[field.index(after: separator)...]
values[key] = value
}
guard let name = values["name"], name.hasPrefix(ZmxSessionID.prefix) else { return nil }
// Absent `clients=` (err/status line) maps to nil = unknown, not zero.
let clients = values["clients"].flatMap { Int($0) }
return Entry(name: String(name), clients: clients)
}
}
}

/// Pure session-ID helpers. zmx's macOS socket-path budget is ~46 chars (sun_path
/// is 104, default socket dir is ~58); `supa-<UUID>` lands at 41, leaving
/// headroom for a longer custom `ZMX_DIR`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Dependencies
import Foundation
import SupacodeSettingsShared

/// Serialized off-main writer for incremental layout persistence. Every flush
/// re-reads `layouts.json` from disk, splices in only the per-worktree keys it
/// carries, then writes the whole dict back through the atomic temp+rename
/// `settingsFileStorage.save`. Being an actor makes the read-modify-write a
/// FIFO critical section: a positive snapshot and a delete tombstone for the
/// same key can't interleave, and concurrent keys from separate flushes both
/// survive (last-writer-wins per key, not whole-dict).
///
/// There is no flock / NSFileCoordinator: a second Supacode instance writing
/// the same file concurrently is a dev-only scenario and accepted as
/// last-writer-wins. The in-memory `@Shared(.layouts)` dict stays the source of
/// truth on main; this actor only owns the encode + disk merge.
actor LayoutsIncrementalWriter {
/// One per-worktree change to splice into the on-disk dict. `.delete` is an
/// explicit tombstone: absence from a flush means "leave the disk key alone",
/// so a pruned worktree must be carried as `.delete`, never as omission.
enum Change: Sendable {
case snapshot(TerminalLayoutSnapshot)
case delete
}

private static let logger = SupaLogger("Layouts")
private let storage: SettingsFileStorage
private let url: URL
/// Guards the read-modify-write so the off-actor `flushSync` (on-quit) and the
/// actor-routed flush/delete paths mutually exclude. The actor still owns FIFO
/// ordering of the live path; this only prevents a lost update against the
/// single off-actor entrant.
private let writeLock = NSLock()

init(
storage: SettingsFileStorage,
url: URL = SupacodePaths.layoutsURL
) {
self.storage = storage
self.url = url
}

/// Re-reads the on-disk dict, applies `changes`, and writes the result.
/// Keys not present in `changes` are preserved from disk untouched.
func flush(_ changes: [String: Change]) {
applyAndWrite(changes)
}

/// Synchronous variant for the on-quit terminal write, where the run loop is
/// tearing down and there's no chance to await the actor. The atomic temp+rename
/// `storage.save` makes the off-actor write safe as the process's final flush.
nonisolated func flushSync(_ changes: [String: Change]) {
applyAndWrite(changes)
}

private nonisolated func applyAndWrite(_ changes: [String: Change]) {
guard !changes.isEmpty else { return }
writeLock.lock()
defer { writeLock.unlock() }
guard var dict = readFromDisk() else {
// Abort rather than splice our keys into an empty dict and clobber every other worktree's layout.
Self.logger.error(
"Aborting incremental layout flush: on-disk layouts failed to decode; preserving file for recovery.")
return
}
let original = dict
for (key, change) in changes {
switch change {
case .snapshot(let snapshot):
dict[key] = snapshot
case .delete:
dict.removeValue(forKey: key)
}
}
// Skip the write when the splice is a no-op. onTabProjectionChanged fires on
// notification / focus / zoom deltas that aren't part of the snapshot, so an
// agent tool-call storm would otherwise churn the file with identical bytes.
guard dict != original else { return }
write(dict)
}

/// Returns the on-disk dict, `[:]` when the file is absent (fresh start) or
/// when corrupt bytes were rotated aside, or `nil` on a present-but-unreadable
/// file (transient/permission error) so the caller aborts rather than clobbers it.
private nonisolated func readFromDisk() -> [String: TerminalLayoutSnapshot]? {
let data: Data
do {
data = try storage.load(url)
} catch {
// Only an absent file is a fresh start; a present-but-unreadable file must abort so we don't clobber siblings.
guard Self.isFileAbsent(error) else {
Self.logger.error("Failed to read layouts during incremental merge: \(error)")
return nil
}
return [:]
}
do {
return try JSONDecoder().decode([String: TerminalLayoutSnapshot].self, from: data)
} catch {
// Corrupt bytes: rotate aside and start fresh rather than refuse to save
// forever. Mirrors SidebarPersistenceKey; the bytes are kept for recovery.
Self.logger.error("Failed to decode layouts during incremental merge: \(error)")
Self.renameCorruptFile(at: url)
return [:]
}
}

/// True only when the read failed because the file does not exist.
private static func isFileAbsent(_ error: Error) -> Bool {
if let cocoa = error as? CocoaError, cocoa.code == .fileReadNoSuchFile { return true }
if let posix = error as? POSIXError, posix.code == .ENOENT { return true }
return false
}

/// Moves a corrupt `layouts.json` aside to `layouts.json.corrupt-<ISO8601>` so
/// the next save starts fresh instead of aborting forever. The storage dep only
/// exposes load/save, so the rename goes through FileManager; a missing or
/// already-renamed file returns quietly and the caller proceeds to the fresh dict.
private nonisolated static func renameCorruptFile(at url: URL) {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: url.path(percentEncoded: false)) else { return }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
let timestamp = formatter.string(from: Date()).replacing(":", with: "-")
let destination = url.deletingLastPathComponent()
.appending(path: "\(url.lastPathComponent).corrupt-\(timestamp)", directoryHint: .notDirectory)
do {
try fileManager.moveItem(at: url, to: destination)
} catch {
Self.logger.warning(
"Failed to rename corrupt layouts file to \(destination.lastPathComponent): \(error).")
}
}

private nonisolated func write(_ dict: [String: TerminalLayoutSnapshot]) {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(dict)
try storage.save(data, url)
} catch {
Self.logger.warning("Failed to write incremental layouts: \(error)")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,14 @@ nonisolated struct LayoutsKey: SharedKey {
}

func save(
_ value: [String: TerminalLayoutSnapshot],
_: [String: TerminalLayoutSnapshot],
context _: SaveContext,
continuation: SaveContinuation
) {
@Dependency(\.settingsFileStorage) var storage
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(value)
try storage.save(data, SupacodePaths.layoutsURL)
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
// No-op: `LayoutsIncrementalWriter` is the sole disk writer for `layouts.json`.
// `@Shared(.layouts)` stays the in-memory source of truth; persisting here too
// would race the actor's per-key merge with a whole-dict last-writer-wins clobber.
continuation.resume()
}
}

Expand Down
Loading
Loading