diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b10e357..c121f4f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: Show Xcode version run: xcodebuild -version + - name: Verify dependency lock + run: node scripts/verify-dependency-lock.mjs + - name: Resolve ghostty SHA id: ghostty run: | @@ -51,6 +54,8 @@ jobs: -scheme Glint \ -configuration Debug \ -derivedDataPath build \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ @@ -63,11 +68,15 @@ jobs: - name: Test bundled CLI run: python3 scripts/test-cli.py build/Build/Products/Debug/Glint.app/Contents/Helpers/glint - - name: Test Web Remote browser input + - name: Test Web Remote browser input and outbound queues run: node --test scripts/test-web-remote-*.mjs - - name: Test Git and persistence regressions - run: node --test scripts/test-git-review.mjs scripts/test-persistence-recovery.mjs + - name: Test native logic regressions + run: >- + node --test scripts/test-git-review.mjs scripts/test-git-untracked.mjs + scripts/test-persistence-recovery.mjs scripts/test-workspace-lifecycle.mjs + scripts/test-control-lifecycle.mjs scripts/test-usage-refresh.mjs + scripts/test-http-lifecycle.mjs scripts/test-web-file-admission.mjs - name: Test Glint run: | @@ -77,6 +86,8 @@ jobs: -scheme GlintTests \ -configuration Debug \ -derivedDataPath build \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01ab3530..fdb921cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,9 @@ jobs: - name: Show Xcode version run: xcodebuild -version + - name: Verify dependency lock + run: node scripts/verify-dependency-lock.mjs + - name: Resolve ghostty SHA id: ghostty run: | @@ -85,6 +88,8 @@ jobs: run: | set -euo pipefail xcodebuild \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ -project Glint.xcodeproj \ -scheme Glint \ -configuration Release \ diff --git a/.gitignore b/.gitignore index 7c2bfc10..ce7ed015 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ xcuserdata/ # Swift Package Manager .build/ Package.resolved +!Glint.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved .swiftpm/ # Build output of scripts/setup-ghosttykit.sh (zig builds it from the diff --git a/Glint.xcodeproj/project.pbxproj b/Glint.xcodeproj/project.pbxproj index cb4d4897..00c740c4 100644 --- a/Glint.xcodeproj/project.pbxproj +++ b/Glint.xcodeproj/project.pbxproj @@ -1134,8 +1134,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.6.4; + kind = exactVersion; + version = 2.6.4; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Glint.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Glint.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..5bc4b0df --- /dev/null +++ b/Glint.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle.git", + "state" : { + "revision" : "0ef1ee0220239b3776f433314515fd849025673f", + "version" : "2.6.4" + } + } + ], + "version" : 2 +} diff --git a/Glint/Agent/ControlBridge.swift b/Glint/Agent/ControlBridge.swift index 4bc59ebe..b0190209 100644 --- a/Glint/Agent/ControlBridge.swift +++ b/Glint/Agent/ControlBridge.swift @@ -26,18 +26,23 @@ final class ControlBridge { private(set) var socketPath: String = "" private(set) var tokenPath: String = "" private var token: String = "" + private let lifecycleLock = NSLock() + private var acceptedClients: Set = [] private var listenFD: Int32 = -1 private var acceptSource: DispatchSourceRead? private let queue = DispatchQueue(label: "glint.control.bridge", qos: .utility) - /// Whether the listener is currently bound. Guards double-start and makes - /// stop() a no-op when already off. Mutated only on the main thread (from - /// the externalControlEnabled toggle / app launch). - private(set) var isRunning = false + /// Lifecycle mutations run on main; accept/serve readers share this lock. + var isRunning: Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + return sessionID != nil + } private let runDirectory: URL private let isEnabled: @MainActor () -> Bool - private var sessionID = UUID() + private var sessionID: UUID? + /// Configure private socket storage and the main-thread access gate; returns a stopped bridge. init(runDirectory: URL? = nil, isEnabled: @escaping @MainActor () -> Bool = { WorkspaceStore.current?.externalControlEnabled ?? false }) { self.runDirectory = runDirectory ?? FileManager.default.homeDirectoryForCurrentUser @@ -81,7 +86,9 @@ final class ControlBridge { tokenPath = tokPath // Fresh token every launch; the old one dies with the process. + lifecycleLock.lock() token = Self.generateToken() + lifecycleLock.unlock() writeToken(to: tokPath) unlink(path) @@ -120,31 +127,36 @@ final class ControlBridge { return } - listenFD = fd - let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + // The listener is nonblocking so a cancelled source cannot hang in a + // queued accept handler while stop waits for the lifecycle lock. + _ = fcntl(fd, F_SETFL, O_NONBLOCK) let sessionID = UUID() + lifecycleLock.lock() + listenFD = fd self.sessionID = sessionID + lifecycleLock.unlock() + let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) src.setEventHandler { [weak self] in self?.acceptOne(listenFD: fd, sessionID: sessionID) } src.setCancelHandler { close(fd) } src.resume() acceptSource = src - isRunning = true NSLog("[glint] control bridge listening on \(path)") } /// Stop listening and remove the socket + token so the /// externalControlEnabled toggle can revoke access live — no app restart. - /// Safe when already stopped. Previously accepted connections can only - /// receive disabled errors, including after a subsequent restart. + /// No inputs or return value. Shutdown accepted sockets now; their serving + /// tasks retain close ownership to prevent descriptor reuse races. func stop() { - guard isRunning else { return } - isRunning = false + lifecycleLock.lock() + guard sessionID != nil else { lifecycleLock.unlock(); return } + sessionID = nil + token = "" + listenFD = -1 + for fd in acceptedClients { _ = shutdown(fd, SHUT_RDWR) } + lifecycleLock.unlock() acceptSource?.cancel() acceptSource = nil - // The cancel handler closes the captured listener after queued accept - // handlers finish, so a restart cannot reuse its fd underneath them. - listenFD = -1 - token = "" if !socketPath.isEmpty { unlink(socketPath) } // Drop the token too — a fresh one is minted on the next start(), so a // stale on-disk token can't authorize anything while we're off. @@ -182,11 +194,12 @@ final class ControlBridge { chmod(path, 0o600) } - /// Constant-time compare — no early return on first mismatch. - private func tokenMatches(_ provided: String?) -> Bool { - guard let provided else { return false } + /// Compare supplied and captured live credentials; inputs are token strings; + /// returns whether they match without early byte exits. + private static func tokenMatches(_ provided: String?, expected: String) -> Bool { + guard let provided, !expected.isEmpty else { return false } let a = Array(provided.utf8) - let b = Array(token.utf8) + let b = Array(expected.utf8) var diff = UInt8(a.count == b.count ? 0 : 1) let n = max(a.count, b.count) for i in 0.. String? { + lifecycleLock.lock() + let active = self.sessionID == sessionID + let expected = token + lifecycleLock.unlock() + guard active else { return "disabled" } + guard !Self.requiresToken(for: command) || Self.tokenMatches(provided, expected: expected) else { + return "unauthorized" + } + return nil + } + // MARK: accept / serve + /// Accept a socket for the captured listener/session; returns no value. private func acceptOne(listenFD: Int32, sessionID: UUID) { + lifecycleLock.lock() + guard self.sessionID == sessionID, self.listenFD == listenFD else { + lifecycleLock.unlock(); return + } let client = accept(listenFD, nil, nil) - guard client >= 0 else { return } + guard client >= 0 else { lifecycleLock.unlock(); return } + _ = fcntl(client, F_SETFL, fcntl(client, F_GETFL) & ~O_NONBLOCK) + acceptedClients.insert(client) + lifecycleLock.unlock() var noSigPipe: Int32 = 1 _ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) var tv = timeval(tv_sec: 5, tv_usec: 0) _ = setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) _ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) - DispatchQueue.global(qos: .utility).async { [weak self] in self?.serve(fd: client, sessionID: sessionID) } + DispatchQueue.global(qos: .utility).async { self.serve(fd: client, sessionID: sessionID) } } /// Runs off the accept queue. Each request line is parsed here, dispatched /// onto the main queue (where the store + surfaces live), and the response /// line written straight back on this connection. + /// Inputs: owned socket and accepting session. Returns nothing and closes the socket on all exits. private func serve(fd: Int32, sessionID: UUID) { - defer { close(fd) } + defer { + lifecycleLock.lock() + acceptedClients.remove(fd) + close(fd) + lifecycleLock.unlock() + } var buf = Data() var tmp = [UInt8](repeating: 0, count: 4096) while true { @@ -252,12 +293,20 @@ final class ControlBridge { } } + /// Handle request bytes for an accepting session; returns JSON after the execution-time gate. private func handle(line: Data, sessionID: UUID) -> Data { guard let obj = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any], let cmd = obj["cmd"] as? String else { return Self.errorResponse("bad-request") } + // Invalid credentials must not wait behind modal UI or layout work. + // The same snapshot check runs again on main to order revocation before + // a request that passed this preflight but was queued during stop(). + if let error = authorizationError(command: cmd, token: obj["token"] as? String, sessionID: sessionID) { + return Self.errorResponse(error) + } + var response = Self.errorResponse("unknown-cmd") var readGrid: (pane: String, data: Data, scrollback: Bool, maxLines: Int)? DispatchQueue.main.sync { @@ -267,11 +316,12 @@ final class ControlBridge { } // Check on the main queue alongside start/stop: disabling access // also revokes commands on already accepted connections. - guard self.isEnabled(), self.isRunning, self.sessionID == sessionID else { + guard self.isEnabled() else { response = Self.errorResponse("disabled"); return } - if Self.requiresToken(for: cmd) && !self.tokenMatches(obj["token"] as? String) { - response = Self.errorResponse("unauthorized"); return + if let error = self.authorizationError(command: cmd, token: obj["token"] as? String, + sessionID: sessionID) { + response = Self.errorResponse(error); return } switch cmd { case "list": diff --git a/Glint/Agent/UsageStore.swift b/Glint/Agent/UsageStore.swift index 90e34962..721cfa90 100644 --- a/Glint/Agent/UsageStore.swift +++ b/Glint/Agent/UsageStore.swift @@ -165,7 +165,11 @@ final class UsageStore: ObservableObject { didSet { guard claudeEnabled != oldValue else { return } UserDefaults.standard.set(claudeEnabled, forKey: Self.claudeKey) - if !claudeEnabled { claude = nil; Self.saveQuota(nil, agent: .claude) } + if !claudeEnabled { + cancelRefresh(for: .claude) + claude = nil + Self.saveQuota(nil, agent: .claude) + } syncTimer() if claudeEnabled { refreshNow() } } @@ -175,7 +179,8 @@ final class UsageStore: ObservableObject { guard codexEnabled != oldValue else { return } UserDefaults.standard.set(codexEnabled, forKey: Self.codexKey) if !codexEnabled { - codexRefreshCoordinator.invalidate() + codexHomeRefreshes.values.forEach { $0.task.cancel(); $0.deadline.cancel() } + codexHomeRefreshes.removeAll() codex = nil codexHomeStatuses = [] Self.saveQuota(nil, agent: .codex) @@ -188,7 +193,11 @@ final class UsageStore: ObservableObject { didSet { guard grokEnabled != oldValue else { return } UserDefaults.standard.set(grokEnabled, forKey: Self.grokKey) - if !grokEnabled { grok = nil; Self.saveQuota(nil, agent: .grok) } + if !grokEnabled { + cancelRefresh(for: .grok) + grok = nil + Self.saveQuota(nil, agent: .grok) + } syncTimer() if grokEnabled { refreshNow() } } @@ -198,14 +207,19 @@ final class UsageStore: ObservableObject { private static let codexKey = "glint.showCodexUsage" private static let grokKey = "glint.showGrokUsage" private var timer: Timer? - private var codexRefreshCoordinator = CodexRefreshCoordinator() + private var agentRefreshes: [Agent: (id: UUID, task: Task, deadline: Task)] = [:] + private var codexHomeRefreshes: [URL: (id: UUID, task: Task, deadline: Task)] = [:] + private let refreshTimeout: TimeInterval /// Refresh cadence. Rate-limit windows move on the order of minutes, so a /// minute of staleness is invisible and keeps disk/network churn trivial. private let interval: TimeInterval = 60 private var anyEnabled: Bool { claudeEnabled || codexEnabled || grokEnabled } - init() { + /// Load cached quotas and start polling; refreshTimeout is each read's + /// deadline in seconds; returns an initialized store without waiting for I/O. + init(refreshTimeout: TimeInterval = 30) { + self.refreshTimeout = refreshTimeout self.claudeEnabled = (UserDefaults.standard.object(forKey: Self.claudeKey) as? Bool) ?? false self.codexEnabled = (UserDefaults.standard.object(forKey: Self.codexKey) as? Bool) ?? false self.grokEnabled = (UserDefaults.standard.object(forKey: Self.grokKey) as? Bool) ?? false @@ -215,15 +229,19 @@ final class UsageStore: ObservableObject { if codexEnabled { self.codex = Self.loadQuota(.codex) } if grokEnabled { self.grok = Self.loadQuota(.grok) } syncTimer() + if anyEnabled { refreshNow() } } - deinit { timer?.invalidate() } + deinit { + timer?.invalidate() + agentRefreshes.values.forEach { $0.task.cancel(); $0.deadline.cancel() } + codexHomeRefreshes.values.forEach { $0.task.cancel(); $0.deadline.cancel() } + } - /// Start or stop the repeating poll to match whether anything is enabled. + /// Start or stop the repeating timer; takes no arguments and returns nothing. private func syncTimer() { if anyEnabled { guard timer == nil else { return } - refreshNow() let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor in self?.refreshNow() } } @@ -236,68 +254,120 @@ final class UsageStore: ObservableObject { } } - /// Refresh whichever agents are currently enabled and publish results. + /// Refresh enabled sources, sharing in-flight requests; takes no arguments and returns nothing. func refreshNow() { - if codexEnabled { - let generation = codexRefreshCoordinator.begin() - Task { [weak self] in - let homes = CodexHomeStore.configuredHomes().filter(\.isEnabled) - await MainActor.run { - guard let self, - self.codexRefreshCoordinator.accepts(generation) else { return } - self.codexHomeStatuses = homes.map { - CodexHomeStatus( - home: $0, - resolvedURL: $0.resolvedURL, - hookStatus: CodexHookInstaller.status(in: $0.resolvedURL), - authStatus: CodexLiveReader.authStatus(from: $0.resolvedURL), - quotaStatus: .loading - ) - } - } - let statuses = await withTaskGroup(of: CodexHomeStatus.self) { group in - for home in homes { - group.addTask { - let quota = await CodexUsageReader.readPreferLive(from: home.resolvedURL) - return CodexHomeStatus( - home: home, - resolvedURL: home.resolvedURL, - hookStatus: CodexHookInstaller.status(in: home.resolvedURL), - authStatus: CodexLiveReader.authStatus(from: home.resolvedURL), - quotaStatus: quota.map(CodexQuotaStatus.available) - ?? .unavailable(String(localized: "Quota unavailable")) - ) - } - } - var byID: [UUID: CodexHomeStatus] = [:] - for await status in group { byID[status.id] = status } - return homes.compactMap { byID[$0.id] } - } - await MainActor.run { - guard let self, - self.codexRefreshCoordinator.accepts(generation) else { return } - self.applyCodex(statuses) - } - } - } + if codexEnabled { refreshCodexHomes() } if claudeEnabled { - Task { [weak self] in - let claude = await ClaudeUsageReader.read() - await MainActor.run { - self?.apply(claude, to: .claude) - } - } + refreshAgent(.claude, read: ClaudeUsageReader.read) } if grokEnabled { - Task { [weak self] in - let quota = await GrokUsageReader.read() - await MainActor.run { - self?.apply(quota, to: .grok) - } + refreshAgent(.grok) { await GrokUsageReader.read() } + } + } + + /// Share one agent read and reject cancelled results; inputs are agent and reader; returns nothing. + private func refreshAgent(_ agent: Agent, read: @escaping () async -> AgentQuota?) { + guard agentRefreshes[agent] == nil else { return } + let id = UUID() + let task = Task { [weak self] in + let quota = await read() + guard !Task.isCancelled, let self, self.agentRefreshes[agent]?.id == id else { return } + self.agentRefreshes.removeValue(forKey: agent)?.deadline.cancel() + self.apply(quota, to: agent) + } + let timeout = refreshTimeout + let deadline = Task { [weak self] in + do { try await Task.sleep(for: .seconds(timeout)) } + catch { return } + guard let self, self.agentRefreshes[agent]?.id == id else { return } + self.cancelRefresh(for: agent) + } + agentRefreshes[agent] = (id, task, deadline) + } + + /// Cancel an agent's pending read and invalidate its identity; input is agent; returns nothing. + private func cancelRefresh(for agent: Agent) { + let refresh = agentRefreshes.removeValue(forKey: agent) + refresh?.task.cancel() + refresh?.deadline.cancel() + } + + /// Reconcile enabled homes and share one read per path; takes no arguments and returns nothing. + private func refreshCodexHomes() { + let homes = CodexHomeStore.configuredHomes().filter(\.isEnabled) + let urls = Set(homes.map(\.resolvedURL)) + for (url, refresh) in codexHomeRefreshes where !urls.contains(url) { + refresh.task.cancel() + refresh.deadline.cancel() + codexHomeRefreshes[url] = nil + } + let previous = Dictionary(uniqueKeysWithValues: codexHomeStatuses.map { ($0.resolvedURL, $0) }) + if !previous.isEmpty && Set(previous.keys) != urls { + codex = nil + Self.saveQuota(nil, agent: .codex) + } + applyCodex(homes.map { home in + CodexHomeStatus( + home: home, + resolvedURL: home.resolvedURL, + hookStatus: CodexHookInstaller.status(in: home.resolvedURL), + authStatus: CodexLiveReader.authStatus(from: home.resolvedURL), + quotaStatus: previous[home.resolvedURL]?.quotaStatus ?? .loading + ) + }) + for home in homes { + let url = home.resolvedURL + guard codexHomeRefreshes[url] == nil else { continue } + let id = UUID() + let task = Task { [weak self] in + let quota = await CodexUsageReader.readPreferLive(from: url) + guard !Task.isCancelled, let self, self.codexHomeRefreshes[url]?.id == id else { return } + self.codexHomeRefreshes.removeValue(forKey: url)?.deadline.cancel() + guard self.codexEnabled, + let currentHome = CodexHomeStore.configuredHomes().first(where: { + $0.isEnabled && $0.resolvedURL == url + }), + let index = self.codexHomeStatuses.firstIndex(where: { $0.resolvedURL == url }) + else { return } + var statuses = self.codexHomeStatuses + statuses[index] = CodexHomeStatus( + home: currentHome, + resolvedURL: url, + hookStatus: CodexHookInstaller.status(in: url), + authStatus: CodexLiveReader.authStatus(from: url), + quotaStatus: quota.map(CodexQuotaStatus.available) + ?? .unavailable(String(localized: "Quota unavailable")) + ) + self.applyCodex(statuses) + } + let timeout = refreshTimeout + let deadline = Task { [weak self] in + do { try await Task.sleep(for: .seconds(timeout)) } + catch { return } + self?.expireHomeRefresh(url: url, id: id) } + codexHomeRefreshes[url] = (id, task, deadline) } } + /// Release a timed-out home read; url and id identify its current request. + /// Returns nothing; preserves usable quota and ends an initial loading state. + private func expireHomeRefresh(url: URL, id: UUID) { + guard let refresh = codexHomeRefreshes[url], refresh.id == id else { return } + codexHomeRefreshes[url] = nil + refresh.task.cancel() + refresh.deadline.cancel() + guard let index = codexHomeStatuses.firstIndex(where: { $0.resolvedURL == url }) else { return } + if case .available = codexHomeStatuses[index].quotaStatus { return } + var statuses = codexHomeStatuses + let old = statuses[index] + statuses[index] = CodexHomeStatus(home: old.home, resolvedURL: url, + hookStatus: old.hookStatus, authStatus: old.authStatus, + quotaStatus: .unavailable(String(localized: "Quota unavailable"))) + applyCodex(statuses) + } + + /// Publish the configured homes and their first usable quota; input is statuses; returns nothing. private func applyCodex(_ statuses: [CodexHomeStatus]) { guard codexEnabled else { return } codexHomeStatuses = statuses @@ -412,6 +482,7 @@ enum CodexUsageReader { /// the main actor since it touches the filesystem. static func readPreferLive(from codexHome: URL = CodexHome.default.resolvedURL) async -> AgentQuota? { if let live = await CodexLiveReader.read(from: codexHome) { return live } + guard !Task.isCancelled else { return nil } return await Task.detached(priority: .utility) { read(from: codexHome) }.value } diff --git a/Glint/Git/GitDiff.swift b/Glint/Git/GitDiff.swift index b5ca4e10..956fbe97 100644 --- a/Glint/Git/GitDiff.swift +++ b/Glint/Git/GitDiff.swift @@ -27,50 +27,67 @@ struct GitFileChange: Identifiable, Equatable { var id: String { path } } +/// One Review list load, including a recoverable untracked failure and the +/// comparison baseline reused by file selection until the next reload. +struct GitReviewChanges { + let files: [GitFileChange] + let untrackedWarning: Bool + /// Fixed OID/empty tree for working-tree review; original range expression for branch review. + let baseline: String +} + extension GitService { /// Return files changed in `repo` under `scope`, sorted by literal path. /// Parameters: repository directory and comparison scope. Returns an empty - /// list for a non-repository or invalid base; never throws. - func changedFiles(repo: String, scope: DiffScope) async -> [GitFileChange] { + /// list when the tracked comparison succeeds without changes. Untracked + /// failures are recoverable; use reviewChanges to also display their warning. + func changedFiles(repo: String, scope: DiffScope) async throws -> [GitFileChange] { + try await reviewChanges(repo: repo, scope: scope).files + } + + /// Load files and their comparison baseline; inputs are repository and scope. + /// Returns partial tracked results if untracked enumeration fails; throws if + /// the baseline or tracked comparison fails. Raw diagnostics stay in logs. + func reviewChanges(repo: String, scope: DiffScope) async throws -> GitReviewChanges { switch scope { case .workingTree: + let base = try await workingTreeDiffBase(repo: repo) // name-status (kind) + numstat (line counts) run concurrently, then // untracked files are appended as additions. `ls-files` runs at the // .read tier alongside its siblings — a slow SSH monorepo can take - // tens of seconds to enumerate untracked entries, and at the .poll - // tier it would SIGTERM, drop to `u.ok == false`, and silently - // omit every untracked file from the Review list (worse than - // truncation: the entries appear to not exist at all). - async let names = git(["diff", "HEAD", "--no-renames", "--name-status", "-z"], cwd: repo, - allowFailure: true, timeout: .read) - async let nums = git(["diff", "HEAD", "--no-renames", "--numstat", "-z"], cwd: repo, - allowFailure: true, timeout: .read) - async let untrk = git(["ls-files", "--others", "--exclude-standard", "-z"], cwd: repo, - allowFailure: true, timeout: .read) + // tens of seconds to enumerate untracked entries, so the shorter + // .poll ceiling can incorrectly interrupt an otherwise healthy read. + async let names = reviewGit(["diff", base, "--no-renames", "--name-status", "-z"], repo: repo) + async let nums = reviewGit(["diff", base, "--no-renames", "--numstat", "-z"], repo: repo) + async let untrk = reviewGit(["ls-files", "--others", "--exclude-standard", "-z"], repo: repo) var map = Self.mergeNameNumstat( - nameStatus: (try? await names)?.stdout ?? "", - numstat: (try? await nums)?.stdout ?? "") - if let u = try? await untrk, u.ok { - let paths = u.stdout.split(separator: "\0", omittingEmptySubsequences: true).map(String.init) + nameStatus: try await names.stdout, + numstat: try await nums.stdout) + var untrackedWarning = false + do { + let paths = try await untrk.stdout.split(separator: "\0", omittingEmptySubsequences: true).map(String.init) let counts = await runner.countUntrackedAdditions(repo: repo, paths: paths) for p in paths { map[p] = GitFileChange(path: p, kind: .untracked, - additions: counts[p] ?? 0, - deletions: 0, isBinary: false) + additions: counts[p] ?? 0, + deletions: 0, isBinary: false) } + } catch { + untrackedWarning = true + NSLog("Review untracked loading failed: %@", error.localizedDescription) } - return withMtime(map.values.sorted { $0.path < $1.path }, repo: repo) + return GitReviewChanges(files: withMtime(map.values.sorted { $0.path < $1.path }, repo: repo), + untrackedWarning: untrackedWarning, baseline: base) case .branch(let base): - async let names = git(["diff", "\(base)...HEAD", "--no-renames", "--name-status", "-z"], cwd: repo, - allowFailure: true, timeout: .read) - async let nums = git(["diff", "\(base)...HEAD", "--no-renames", "--numstat", "-z"], cwd: repo, - allowFailure: true, timeout: .read) + async let names = reviewGit(["diff", "\(base)...HEAD", "--no-renames", "--name-status", "-z"], repo: repo) + async let nums = reviewGit(["diff", "\(base)...HEAD", "--no-renames", "--numstat", "-z"], repo: repo) let map = Self.mergeNameNumstat( - nameStatus: (try? await names)?.stdout ?? "", - numstat: (try? await nums)?.stdout ?? "") - return withMtime(map.values.sorted { $0.path < $1.path }, repo: repo) + nameStatus: try await names.stdout, + numstat: try await nums.stdout) + return GitReviewChanges(files: withMtime(map.values.sorted { $0.path < $1.path }, repo: repo), + untrackedWarning: false, baseline: "\(base)...HEAD") } } @@ -89,7 +106,10 @@ extension GitService { } } - /// Unified-diff text for one file under `scope` (empty string on failure). + /// Unified-diff text for one file; throws on Git failure or interruption. + /// Parameters: repository, scope, changed file, whitespace preference and + /// an optional working-tree baseline from the same Review list load. + /// Returns complete diff text, including an empty successful diff. /// `ignoreWhitespace` adds `--ignore-all-space` (indentation/whitespace-only /// changes collapse to context) — a load-time flag, not a render filter. /// @@ -97,15 +117,15 @@ extension GitService { /// stdout is whatever was drained before SIGTERM — a mid-stream truncation /// of the unified diff. Returning that partial text would render a /// complete-looking diff with the tail silently missing, so on signal we - /// drop the bytes and return empty (same shape as a launch failure). + /// drop the bytes and throw rather than render a complete-looking empty diff. func fileDiff(repo: String, scope: DiffScope, file: GitFileChange, - ignoreWhitespace: Bool = false) async -> String { + ignoreWhitespace: Bool = false, baseline: String? = nil) async throws -> String { // Huge -U makes git emit the entire file as context (clamped to file // length), so "Show All" renders the whole file and "Changes Only" // just filters context at render time. One load serves both states. var args = ["diff", "--unified=1000000"] if ignoreWhitespace { args.append("--ignore-all-space") } - let result: GitResult? + let result: GitResult switch scope { case .workingTree: if file.kind == .untracked { @@ -114,18 +134,52 @@ extension GitService { // Prepend `args` so the toolbar's Show All / Ignore Whitespace // toggles apply uniformly — without this, untracked files were // silently exempt from the menu state. - result = try? await git(args + ["--no-index", "--", "/dev/null", file.path], - cwd: repo, allowFailure: true, timeout: .read) + result = try await reviewGit(args + ["--no-index", "--", "/dev/null", file.path], + repo: repo, allowedExitCodes: [0, 1]) } else { - result = try? await git(args + ["HEAD", "--", file.path], - cwd: repo, allowFailure: true, timeout: .read) + let base: String + if let baseline { base = baseline } + else { base = try await workingTreeDiffBase(repo: repo) } + result = try await reviewGit(args + [base, "--", file.path], repo: repo) } case .branch(let base): - result = try? await git(args + ["\(base)...HEAD", "--", file.path], - cwd: repo, allowFailure: true, timeout: .read) + result = try await reviewGit(args + ["\(base)...HEAD", "--", file.path], repo: repo) + } + return result.stdout + } + + /// Validate a Review read. Parameters: Git argv, repository and permitted + /// exit codes. Returns complete output; throws for interruption or failure. + private func reviewGit(_ args: [String], repo: String, + allowedExitCodes: [Int32] = [0]) async throws -> GitResult { + let result = try await git(args, cwd: repo, allowFailure: true, timeout: .read) + guard !result.wasSignaled, allowedExitCodes.contains(result.exitCode) else { + throw GitError.commandFailed(args: args, exitCode: result.exitCode, stderr: result.stderr) + } + return result + } + + /// Resolve the working-tree baseline. Parameter: repository directory. + /// Returns the resolved HEAD OID or repository-format empty tree for an unborn branch; + /// throws for invalid repositories, broken refs and other Git failures. + private func workingTreeDiffBase(repo: String) async throws -> String { + let headArgs = ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"] + let head = try await reviewGit(headArgs, repo: repo, allowedExitCodes: [0, 1]) + if head.ok { return head.stdout.trimmingCharacters(in: .whitespacesAndNewlines) } + guard head.stderr.isEmpty else { + throw GitError.commandFailed(args: headArgs, exitCode: head.exitCode, stderr: head.stderr) + } + let branch = try await reviewGit(["symbolic-ref", "--quiet", "HEAD"], repo: repo) + .stdout.trimmingCharacters(in: .whitespacesAndNewlines) + let existing = try await reviewGit(["show-ref", "--verify", "--quiet", branch], + repo: repo, allowedExitCodes: [0, 1]) + guard existing.exitCode == 1, existing.stderr.isEmpty else { + throw GitError.commandFailed(args: headArgs, exitCode: head.exitCode, stderr: head.stderr) } - guard let r = result, !r.wasSignaled else { return "" } - return r.stdout + // Hashing the empty tree uses this repository's object format without + // writing an object or assuming SHA-1's well-known empty-tree hash. + return try await reviewGit(["hash-object", "-t", "tree", "--", "/dev/null"], repo: repo) + .stdout.trimmingCharacters(in: .whitespacesAndNewlines) } // MARK: parsing diff --git a/Glint/Git/GitService.swift b/Glint/Git/GitService.swift index 257aa2fd..e004fe17 100644 --- a/Glint/Git/GitService.swift +++ b/Glint/Git/GitService.swift @@ -1,4 +1,5 @@ import CryptoKit +import Darwin import Foundation // MARK: - Out-of-band git @@ -351,9 +352,26 @@ struct LocalGitRunner: GitRunner { /// so non-UTF-8 text (Latin-1, Shift-JIS) counts identically to git /// (`numstat` doesn't decode either — the earlier `String(data:encoding:.utf8)` /// dropped any non-UTF-8 file to 0). + /// Parameters: repository directory and relative file path. Returns the + /// addition count, or zero for unreadable/non-regular/oversized files. private static func addCountFromBytes(repo: String, relPath: String) -> Int { let abs = (repo as NSString).appendingPathComponent(relPath) - guard let data = FileManager.default.contents(atPath: abs), !data.isEmpty, + guard let attributes = try? FileManager.default.attributesOfItem(atPath: abs), + attributes[.type] as? FileAttributeType == .typeRegular, + let size = attributes[.size] as? NSNumber, + size.int64Value <= Int64(maxLocalCountBytes) else { return 0 } + // The path can be replaced after preflight: never block on a FIFO or + // follow a new symlink, and validate the descriptor we actually opened. + let descriptor = Darwin.open(abs, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { return 0 } + defer { _ = Darwin.close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG), + info.st_size <= off_t(maxLocalCountBytes) else { return 0 } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + // A regular file may grow after stat; cap the read itself as well. + guard let data = try? handle.read(upToCount: maxLocalCountBytes + 1), !data.isEmpty, data.count <= maxLocalCountBytes else { return 0 } return data.withUnsafeBytes { buf -> Int in let bytes = buf.bindMemory(to: UInt8.self) diff --git a/Glint/Pane/GhosttySurfaceView.swift b/Glint/Pane/GhosttySurfaceView.swift index 53632fb6..8b54a53b 100644 --- a/Glint/Pane/GhosttySurfaceView.swift +++ b/Glint/Pane/GhosttySurfaceView.swift @@ -580,7 +580,8 @@ final class GhosttySurfaceView: NSView, NSTextInputClient { /// which is what corrupted the earlier byte-tee approach). Decoding that /// JSON, rebuilding ANSI text, and the disk write all happen on the archive /// queue, which also drops the frame early when the grid hasn't changed. - func flushScrollbackToDisk() { + /// Snapshot this surface; force bypasses the daily dirty optimization; returns no value and queues archive I/O. + func flushScrollbackToDisk(force: Bool = false) { guard let id = scrollbackID, let s = surface else { return } // Per-pane dirty bit. Without it each fallback flush serializes every live // pane's full grid (viewport + 3000 scrollback rows × per-cell color/ @@ -591,7 +592,7 @@ final class GhosttySurfaceView: NSView, NSTextInputClient { // the C API), but covering user input + our own writes + foreground- // pid changes catches the common idle case (a finished Claude/Codex // turn) and lets idle panes settle to zero cost. - guard scrollbackNeedsFlush else { return } + guard force || scrollbackNeedsFlush else { return } let json = ghostty_surface_render_grid_json(s, "", 0, 0, UInt(maxScrollbackLines)) defer { ghostty_string_free(json) } guard let ptr = json.ptr, json.len > 0 else { return } diff --git a/Glint/Resources/Localizable.xcstrings b/Glint/Resources/Localizable.xcstrings index 9a07a271..b5d191cd 100644 --- a/Glint/Resources/Localizable.xcstrings +++ b/Glint/Resources/Localizable.xcstrings @@ -773,6 +773,28 @@ } } }, + "Archive Workspace": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档工作区" + } + } + } + }, + "Archive “%@”?": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档“%@”?" + } + } + } + }, "Archived": { "extractionState": "manual", "localizations": { @@ -6317,6 +6339,116 @@ } } } + }, + "Couldn't load changed files. Try refreshing.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载更改文件,请刷新重试。" + } + } + } + }, + "Couldn't load the diff. Try refreshing.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载差异,请刷新重试。" + } + } + } + }, + "Untracked files couldn't be loaded. Tracked changes are still available.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载未跟踪文件,仍可查看已跟踪文件的更改。" + } + } + } + }, + "1 of its panes still has something running; archiving will terminate its processes.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其中 1 个面板仍有任务在运行,归档将终止其中的进程。" + } + } + } + }, + "%lld of its panes still have something running; archiving will terminate their processes.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其中 %lld 个面板仍有任务在运行,归档将终止这些进程。" + } + } + } + }, + "Saved Session Unavailable": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取已保存会话" + } + } + } + }, + "Your saved session is still on disk. Retry will restore it, replacing this temporary workspace session and closing its terminals. Continuing without saving keeps the original untouched; temporary workspace changes will not be saved.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保存的会话仍保留在磁盘原处。重试将恢复它,替换当前临时工作区会话并关闭其中的终端。继续而不保存会保留原存档;临时工作区的更改不会保存。" + } + } + } + }, + "The temporary session changed while the dialog was open. Review it before trying again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "对话框打开期间临时会话发生了变化,请检查后重试。" + } + } + } + }, + "Retry and Restore": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试并恢复" + } + } + } + }, + "Continue Without Saving": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续临时会话(不保存)" + } + } + } } }, "version": "1.0" diff --git a/Glint/Resources/WebRemote/web-remote.js b/Glint/Resources/WebRemote/web-remote.js index 900b1883..03c0b317 100644 --- a/Glint/Resources/WebRemote/web-remote.js +++ b/Glint/Resources/WebRemote/web-remote.js @@ -95,6 +95,9 @@ const translations = { invalid_file_path: "文件路径无效或超出项目目录", file_unavailable: "文件或目录无法读取", file_too_large: "文件超过 128 KB,无法预览", + file_busy: "文件读取繁忙,请稍后重试;终端仍可使用", + response_too_large: "响应过大,无法传输,请选择较小内容", + send_queue_busy: "连接发送繁忙,请重试未完成的操作", file_not_text: "仅支持预览 UTF-8 文本文件", invalid_project_path: "目录不存在,或不是这台 Mac 上可访问的文件夹。", loading_terminal: "正在载入终端", @@ -167,6 +170,9 @@ const translations = { invalid_file_path: "File path is invalid or outside the workspace", file_unavailable: "File or folder cannot be read", file_too_large: "Files over 128 KB cannot be previewed", + file_busy: "File reads are busy. Try again shortly; the terminal is still available.", + response_too_large: "The response is too large to transfer. Choose smaller content.", + send_queue_busy: "The connection is busy sending. Retry unfinished operations.", file_not_text: "Only UTF-8 text files can be previewed", invalid_project_path: "The directory does not exist or is not accessible on this Mac.", loading_terminal: "Loading terminal", @@ -714,7 +720,7 @@ function handleMessage(raw) { setStatus("connected", t("terminal_closed")); break; case "error": - handleError(message.code); + handleError(message.code, message.pane); break; } } @@ -796,8 +802,18 @@ function applyTheme(theme) { }; } -/** Update protocol error UI; takes a server error code and returns nothing. */ -function handleError(code) { +/** Update protocol error UI; code and optional pane identify the failure; returns nothing. */ +function handleError(code, pane) { + if (code === "send-queue-busy") { + fileContentRequest = ""; + fileListRequest = ""; + elements.filesMessage.textContent = t("send_queue_busy"); + } + if ((code === "response-too-large" || code === "snapshot-busy") && pane === selectedPane) { + controllingPane = ""; + clearTimeout(paneRetryTimer); + paneRetryCount = paneRetryLimit; + } if (code === "unauthorized") { if (encrypted) { socket?.close(); @@ -832,6 +848,9 @@ function handleError(code) { function errorLabel(code) { const labels = { + "response-too-large": t("response_too_large"), + "snapshot-busy": t("send_queue_busy"), + "send-queue-busy": t("send_queue_busy"), "bad-request": t("bad_request"), "unknown-pane": t("unknown_pane"), "unknown-workspace": t("unknown_workspace"), @@ -850,6 +869,8 @@ function fileErrorLabel(code) { "file-root-changed": "file_root_changed", "invalid-file-path": "invalid_file_path", "file-unavailable": "file_unavailable", + "file-busy": "file_busy", + "response-too-large": "response_too_large", "file-too-large": "file_too_large", "file-not-text": "file_not_text", "image-too-large": "image_too_large", diff --git a/Glint/Review/ReviewWindow.swift b/Glint/Review/ReviewWindow.swift index 9964687f..351db701 100644 --- a/Glint/Review/ReviewWindow.swift +++ b/Glint/Review/ReviewWindow.swift @@ -44,10 +44,15 @@ final class ReviewModel: ObservableObject { @Published var diff: DiffDocument = .empty // parsed once per file, off-main @Published var loadingFiles = false @Published var loadingDiff = false + @Published private(set) var filesError: String? + @Published private(set) var filesWarning: String? + @Published private(set) var diffError: String? private let git: GitService private var fileLoadToken = 0 // guards against out-of-order file-list loads private var diffLoadToken = 0 // guards against out-of-order diff loads + private var loadedBaseline: String? + private var loadedScope: DiffScope? init(repo: String, title: String, subdir: String? = nil, scopes: [DiffScope], isRemote: Bool = false, @@ -61,14 +66,41 @@ final class ReviewModel: ObservableObject { self.git = GitService(runner: runner) } + /// Reload Review files. Parameters: none. Returns nothing; publishes a + /// localized failure state rather than treating Git errors as no changes. func reload() async { // Guard against overlapping reloads (rapid scope toggles): a slower, // older fetch must not clobber the newer scope's file list. fileLoadToken += 1 let token = fileLoadToken + let requestedScope = scope + let selectedPath = selected?.path + clearSelection() loadingFiles = true - var fs = await git.changedFiles(repo: repo, scope: scope) - guard token == fileLoadToken else { return } + filesError = nil + filesWarning = nil + loadedBaseline = nil + let loaded: GitReviewChanges + do { + loaded = try await git.reviewChanges(repo: repo, scope: requestedScope) + } catch { + guard token == fileLoadToken, scope == requestedScope else { return } + NSLog("Review file loading failed: %@", error.localizedDescription) + loadingFiles = false + files = [] + tree = [] + combined = [] + clearSelection() + filesError = String(localized: "Couldn't load changed files. Try refreshing.") + return + } + var fs = loaded.files + guard token == fileLoadToken, scope == requestedScope else { return } + loadedBaseline = loaded.baseline + loadedScope = requestedScope + if loaded.untrackedWarning { + filesWarning = String(localized: "Untracked files couldn't be loaded. Tracked changes are still available.") + } // Scope the file list to the focused pane's subtree when reviewing a // subdirectory. Git reports root-relative paths, so a prefix filter // (exact match for the dir itself, or "/" for contents) is @@ -85,7 +117,7 @@ final class ReviewModel: ObservableObject { combined = Self.groupByDir(fs) // Keep the current file selected if it still exists, else fall back to // the first change so the diff pane is never blank when there's content. - if let sel = selected, let still = fs.first(where: { $0.path == sel.path }) { + if let path = selectedPath, let still = fs.first(where: { $0.path == path }) { await select(still) } else if let first = fs.first { await select(first) @@ -109,34 +141,51 @@ final class ReviewModel: ObservableObject { } } + /// Load a selected file. Parameter: changed file. Returns nothing; publishes + /// complete diff content or a localized error for the current selection. func select(_ f: GitFileChange) async { + guard !loadingFiles, loadedScope == nil || loadedScope == scope else { return } selected = f diffLoadToken += 1 let token = diffLoadToken + let requestedScope = scope loadingDiff = true diffText = "" diff = .empty - let text = await git.fileDiff(repo: repo, scope: scope, file: f, ignoreWhitespace: ignoreWhitespace) + diffError = nil + let text: String + do { + text = try await git.fileDiff(repo: repo, scope: requestedScope, file: f, + ignoreWhitespace: ignoreWhitespace, baseline: loadedBaseline) + } catch { + guard token == diffLoadToken, scope == requestedScope else { return } + NSLog("Review diff loading failed: %@", error.localizedDescription) + diffError = String(localized: "Couldn't load the diff. Try refreshing.") + loadingDiff = false + return + } // A newer selection (or scope change) superseded this load — drop it. - guard token == diffLoadToken else { return } + guard token == diffLoadToken, scope == requestedScope else { return } // Parse off the main actor so a multi-thousand-line diff doesn't hitch // the UI; the result is cached so re-renders (splitter drags) don't reparse. let language = SyntaxLanguage.from(path: f.path) let doc = await Task.detached(priority: .userInitiated) { DiffDocument(text: text, language: language) }.value - guard token == diffLoadToken else { return } + guard token == diffLoadToken, scope == requestedScope else { return } diffText = text diff = doc loadingDiff = false } + /// Clear the selected diff and invalidate pending reads. Parameters: none. Returns nothing. func clearSelection() { diffLoadToken += 1 selected = nil diffText = "" diff = .empty loadingDiff = false + diffError = nil } static func revealTarget(fullPath: String, repo: String, @@ -298,7 +347,20 @@ private struct FileListView: View { Rectangle().fill(Theme.overlay(0.05)).frame(height: 1) ScrollView { LazyVStack(alignment: .leading, spacing: 2) { - if model.files.isEmpty && !model.loadingFiles { + if let warning = model.filesWarning { + Text(warning) + .font(.system(size: 12)) + .foregroundStyle(Theme.text3) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } + if let error = model.filesError { + Text(error) + .font(.system(size: 12)) + .foregroundStyle(Theme.text3) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 40) + } else if model.files.isEmpty && !model.loadingFiles && model.filesWarning == nil { Text("No changes") .font(.system(size: 12)) .foregroundStyle(Theme.text3) @@ -450,6 +512,7 @@ private struct FileListView: View { } private var fileCountText: String { + if model.filesError != nil { return "" } if model.files.isEmpty { return String(localized: "No changes") } let count = displayedFiles.count switch count { @@ -872,10 +935,12 @@ private struct DiffPaneView: View { @ViewBuilder private var content: some View { - if model.loadingDiff && model.diffText.isEmpty { + if let error = model.filesError ?? model.diffError { + hint(error) + } else if model.loadingFiles || (model.loadingDiff && model.diffText.isEmpty) { hint(String(localized: "Loading…")) } else if model.selected == nil { - hint(String(localized: "No changes to review")) + hint(model.filesWarning ?? String(localized: "No changes to review")) } else if model.selected?.isBinary == true { hint(String(localized: "Binary file — no text diff")) } else if model.diffText.isEmpty { diff --git a/Glint/WebRemote/WebRemoteProtocol.swift b/Glint/WebRemote/WebRemoteProtocol.swift index 6c8f0fe1..313d25c6 100644 --- a/Glint/WebRemote/WebRemoteProtocol.swift +++ b/Glint/WebRemote/WebRemoteProtocol.swift @@ -326,6 +326,7 @@ enum WebRemoteFiles { } enum ErrorCode: String, Error { + case busy = "file-busy" case rootChanged = "file-root-changed" case rootUnavailable = "files-unavailable" case invalidPath = "invalid-file-path" diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift index 1d3a2737..d69a4fb5 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -83,21 +83,111 @@ struct WebRemoteOutboundBuffer { } let maxQueuedOutputBytes: Int + let maxQueuedBytes: Int + let maxQueuedItems: Int private var items: [Item] = [] private var queuedOutputBytes = 0 - - init(maxQueuedOutputBytes: Int) { + private var queuedBytes = 0 + private var queuedMessageBytes = 0 + private struct RejectionNotice { + let data: Data + var itemsAhead: Int + let coalesced: Bool + let streamBoundaryPane: String? + let snapshot: Bool + } + private var rejectionNotices: [RejectionNotice] = [] + private var rejectionBytes = 0 + private var hasBusyNotice = false + + /// Ordinary responses leave the terminal stream's byte allowance available. + private var maxQueuedMessageBytes: Int { max(0, maxQueuedBytes - maxQueuedOutputBytes) } + + /// Set stream, total-byte and item budgets; returns an empty response queue. + /// The total allows a 5 MiB image after base64/JSON encoding and snapshots. + init(maxQueuedOutputBytes: Int, maxQueuedBytes: Int = 16 * 1024 * 1024, + maxQueuedItems: Int = 256) { precondition(maxQueuedOutputBytes >= 0) + precondition(maxQueuedBytes >= 0 && maxQueuedItems > 0) self.maxQueuedOutputBytes = maxQueuedOutputBytes + self.maxQueuedBytes = maxQueuedBytes + self.maxQueuedItems = maxQueuedItems + } + + /// Queue a JSON response; data is its encoded body; returns false at capacity. + @discardableResult + mutating func enqueueMessage(_ data: Data) -> Bool { + // An existing output item may precede messages, so it cannot absorb the + // next bytes. Always leave a slot for that next terminal segment. + let messageItemLimit = maxQueuedItems - 1 + guard data.count <= maxQueuedMessageBytes - queuedMessageBytes, + data.count <= maxQueuedBytes - queuedBytes, + items.count < messageItemLimit else { return false } + items.append(.message(data)) + queuedBytes += data.count + queuedMessageBytes += data.count + return true } - mutating func enqueueMessage(_ data: Data) { - items.append(.message(data)) + /// Record a rejected response without retaining its body; data identifies + /// the original request. Returns nothing. Ordinary notices use 64 KiB/32 + /// slots, plus one coalesced busy and one latest control notice (under 2 KiB). + mutating func rejectMessage(_ data: Data) { + let tooLarge = data.count > maxQueuedMessageBytes + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + if object?["type"] as? String == "snapshot" { + // Only the latest pending pane selection can establish control. + // Reserve one small control notice so a file-error flood cannot + // turn its rejection into an unrelated file-pressure notification. + let pane = String(decoding: (object?["pane"] as? String ?? "").utf8.prefix(256), as: UTF8.self) + let control: [String: String] = [ + "type": "error", "pane": pane, + "code": tooLarge ? "response-too-large" : "snapshot-busy", + ] + guard let encoded = try? JSONSerialization.data(withJSONObject: control) else { return } + rejectionBytes -= rejectionNotices.filter(\.snapshot).reduce(0) { $0 + $1.data.count } + rejectionNotices.removeAll { $0.snapshot } + rejectionNotices.append(RejectionNotice(data: encoded, itemsAhead: items.count, + coalesced: false, streamBoundaryPane: pane, snapshot: true)) + rejectionBytes += encoded.count + return + } + let fileResponse = ["fileList", "fileContent", "fileImage", "fileError"] + .contains(object?["type"] as? String ?? "") + var notice: [String: Any] = [ + "type": fileResponse ? "fileError" : "error", + "code": tooLarge ? "response-too-large" : (fileResponse ? "file-busy" : "send-queue-busy"), + ] + for key in fileResponse ? ["workspace", "pane", "path", "root", "request"] : ["pane"] { + if let value = object?[key] as? String { notice[key] = value } + } + guard let encoded = try? JSONSerialization.data(withJSONObject: notice), + encoded.count <= 64 * 1024 - rejectionBytes, + rejectionNotices.count < 32 else { + if !hasBusyNotice { + let busy = Data(#"{"type":"error","code":"send-queue-busy"}"#.utf8) + rejectionNotices.append(RejectionNotice(data: busy, itemsAhead: items.count, + coalesced: true, streamBoundaryPane: nil, snapshot: false)) + rejectionBytes += busy.count + hasBusyNotice = true + } + return + } + rejectionNotices.append(RejectionNotice(data: encoded, itemsAhead: items.count, + coalesced: false, streamBoundaryPane: nil, snapshot: false)) + rejectionBytes += encoded.count } + /// Queue terminal bytes for a pane; returns false when either budget is full. mutating func enqueueTerminalOutput(_ data: Data, pane: String) -> Bool { - guard data.count <= maxQueuedOutputBytes - queuedOutputBytes else { return false } - if let last = items.last, + guard data.count <= maxQueuedOutputBytes - queuedOutputBytes, + data.count <= maxQueuedBytes - queuedBytes else { return false } + // File errors do not revoke terminal control and can share its existing + // segment. A rejected snapshot is a control boundary for that pane. + let crossesSnapshotBoundary = rejectionNotices.contains { + $0.itemsAhead == items.count && $0.streamBoundaryPane == pane + } + if !crossesSnapshotBoundary, let last = items.last, case let .terminalOutput(existingPane, existingData) = last, existingPane == pane { var combined = existingData @@ -107,24 +197,49 @@ struct WebRemoteOutboundBuffer { data: combined ) } else { + guard items.count < maxQueuedItems else { return false } items.append(.terminalOutput(pane: pane, data: data)) } queuedOutputBytes += data.count + queuedBytes += data.count return true } + /// Remove the next response and release its budget; no parameters; returns nil if empty. mutating func next() -> Item? { + // A later error must not overtake an older snapshot for the same pane: + // the browser could otherwise restore control from that stale snapshot. + if rejectionNotices.first?.itemsAhead == 0 { + let notice = rejectionNotices.removeFirst() + rejectionBytes -= notice.data.count + if notice.coalesced { hasBusyNotice = false } + return .message(notice.data) + } guard !items.isEmpty else { return nil } let item = items.removeFirst() - if case let .terminalOutput(_, data) = item { + for index in rejectionNotices.indices where rejectionNotices[index].itemsAhead > 0 { + rejectionNotices[index].itemsAhead -= 1 + } + switch item { + case let .terminalOutput(_, data): queuedOutputBytes -= data.count + queuedBytes -= data.count + case let .message(data): + queuedBytes -= data.count + queuedMessageBytes -= data.count } return item } + /// Clear queued responses and all accounting; no parameters or return value. mutating func removeAll() { items.removeAll() queuedOutputBytes = 0 + queuedBytes = 0 + queuedMessageBytes = 0 + rejectionNotices.removeAll() + rejectionBytes = 0 + hasBusyNotice = false } } @@ -168,6 +283,7 @@ final class WebRemoteServer: @unchecked Sendable { static let maxClientConnections = 32 static let maxUnauthenticatedConnections = 8 + static let maxHTTPConnections = 32 private static let maxIngressOutputBytes = 256 * 1024 private static let maxSelectionOutputBytes = 512 * 1024 private static let authFailureCountCap = 7 @@ -228,6 +344,13 @@ final class WebRemoteServer: @unchecked Sendable { private var httpListener: NWListener? private var webSocketListener: NWListener? private var clients: [UUID: WebRemoteClientConnection] = [:] + private var httpConnections: [ObjectIdentifier: NWConnection] = [:] + private var httpHeadersPending = Set() + private var httpSendGenerations: [ObjectIdentifier: UUID] = [:] + private var httpSendTimeouts: [ObjectIdentifier: DispatchWorkItem] = [:] + private var activeFileRequests: [UUID: UUID] = [:] + private let httpRequestTimeout: TimeInterval + private let httpSendIdleTimeout: TimeInterval private var authThrottle = WebRemoteAuthThrottle( countCap: WebRemoteServer.authFailureCountCap, expirySeconds: WebRemoteServer.authFailureExpirySeconds @@ -255,10 +378,14 @@ final class WebRemoteServer: @unchecked Sendable { init( defaults: UserDefaults = .standard, interfaceRetryInterval: TimeInterval = 5, + httpRequestTimeout: TimeInterval = 10, + httpSendIdleTimeout: TimeInterval = 30, resolveBindAddress: @escaping (String) -> String? = WebRemoteListenTarget.bindAddress ) { self.defaults = defaults self.interfaceRetryInterval = interfaceRetryInterval + self.httpRequestTimeout = httpRequestTimeout + self.httpSendIdleTimeout = httpSendIdleTimeout self.resolveBindAddress = resolveBindAddress ports = WebRemotePortStore.loadOrCreate(defaults: defaults) } @@ -449,7 +576,8 @@ final class WebRemoteServer: @unchecked Sendable { configure(http, kind: .http, runID: currentRun) configure(webSocket, kind: .webSocket, runID: currentRun) http.newConnectionHandler = { [weak self] connection in - self?.handleHTTPConnection(connection) + guard let self else { connection.cancel(); return } + self.handleHTTPConnection(connection, runID: currentRun) } webSocket.newConnectionHandler = { [weak self] connection in self?.handleWebSocketConnection(connection) @@ -607,6 +735,15 @@ final class WebRemoteServer: @unchecked Sendable { webSocketListener = nil clients.values.forEach { $0.cancel() } clients.removeAll() + for connection in httpConnections.values { + connection.stateUpdateHandler = nil + connection.cancel() + } + httpConnections.removeAll() + httpHeadersPending.removeAll() + httpSendTimeouts.values.forEach { $0.cancel() } + httpSendTimeouts.removeAll() + httpSendGenerations.removeAll() authThrottle.removeAll() readyListeners.removeAll() token = "" @@ -642,28 +779,56 @@ final class WebRemoteServer: @unchecked Sendable { } } - private func handleHTTPConnection(_ connection: NWConnection) { + /// Track a connection for this listener generation; returns nothing. The + /// fixed deadline covers only headers; response sends have progress deadlines. + private func handleHTTPConnection(_ connection: NWConnection, runID: UUID) { + guard self.runID == runID, httpConnections.count < Self.maxHTTPConnections else { + connection.cancel() + return + } + let id = ObjectIdentifier(connection) + httpConnections[id] = connection + httpHeadersPending.insert(id) connection.stateUpdateHandler = { [weak self, weak connection] state in - guard let self, let connection else { return } + guard let self, let connection, self.httpConnections[id] === connection else { return } switch state { case .ready: self.receiveHTTPRequest(connection, buffer: Data()) case .failed, .cancelled: - connection.stateUpdateHandler = nil + self.finishHTTPConnection(connection) default: break } } connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + httpRequestTimeout) { [weak self, weak connection] in + guard let self, let connection, self.httpConnections[id] === connection, + self.httpHeadersPending.contains(id) else { return } + self.finishHTTPConnection(connection) + } + } + + /// Remove and cancel a tracked HTTP connection; no return value. + private func finishHTTPConnection(_ connection: NWConnection) { + httpConnections.removeValue(forKey: ObjectIdentifier(connection)) + let id = ObjectIdentifier(connection) + httpHeadersPending.remove(id) + httpSendTimeouts.removeValue(forKey: id)?.cancel() + httpSendGenerations.removeValue(forKey: id) + connection.stateUpdateHandler = nil + connection.cancel() } + /// Receive bounded headers for a tracked connection; buffer is accumulated + /// header data. Returns nothing and stops on completion, failure or deadline. private func receiveHTTPRequest(_ connection: NWConnection, buffer: Data) { connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self, weak connection] content, _, complete, error in - guard let self, let connection else { return } + guard let self, let connection, + self.httpConnections[ObjectIdentifier(connection)] === connection else { return } if error != nil { - connection.cancel() + self.finishHTTPConnection(connection) return } var next = buffer @@ -673,6 +838,7 @@ final class WebRemoteServer: @unchecked Sendable { return } if next.range(of: Data([13, 10, 13, 10])) != nil { + self.httpHeadersPending.remove(ObjectIdentifier(connection)) self.serveHTTPRequest(next, on: connection) return } @@ -731,9 +897,36 @@ final class WebRemoteServer: @unchecked Sendable { sendHTTP(response, to: connection) } + /// Send a response without a total transfer deadline; data and connection + /// are inputs. Returns nothing; each bounded chunk must make send progress. private func sendHTTP(_ data: Data, to connection: NWConnection) { - connection.send(content: data, completion: .contentProcessed { _ in - connection.cancel() + httpHeadersPending.remove(ObjectIdentifier(connection)) + sendHTTPChunk(data, offset: 0, to: connection) + } + + /// Send the next 64 KiB response chunk; data, byte offset and connection + /// identify the transfer. Returns nothing; stale completions cannot restart it. + private func sendHTTPChunk(_ data: Data, offset: Int, to connection: NWConnection) { + let id = ObjectIdentifier(connection) + guard httpConnections[id] === connection else { return } + guard offset < data.count else { finishHTTPConnection(connection); return } + let generation = UUID() + httpSendGenerations[id] = generation + let timeout = DispatchWorkItem { [weak self, weak connection] in + guard let self, let connection, self.httpSendGenerations[id] == generation else { return } + self.finishHTTPConnection(connection) + } + httpSendTimeouts[id]?.cancel() + httpSendTimeouts[id] = timeout + queue.asyncAfter(deadline: .now() + httpSendIdleTimeout, execute: timeout) + let end = min(offset + 64 * 1024, data.count) + connection.send(content: data.subdata(in: offset.. Bool { + guard let data = SafeJSON.data(object) else { return false } + return clients[clientID]?.send(data) ?? false } private func sendError(_ code: String, to clientID: UUID) { @@ -1327,6 +1556,9 @@ private final class WebRemoteClientConnection: @unchecked Sendable { ) private var sendInFlight = false private var cancelled = false + private var sendQueue: DispatchQueue? + private var sendGeneration: UUID? + private var sendTimeout: DispatchWorkItem? init( id: UUID, @@ -1341,6 +1573,7 @@ private final class WebRemoteClientConnection: @unchecked Sendable { } func start(on queue: DispatchQueue, authenticationTimeout: TimeInterval) { + sendQueue = queue connection.stateUpdateHandler = { [weak self] state in guard let self else { return } switch state { @@ -1373,6 +1606,9 @@ private final class WebRemoteClientConnection: @unchecked Sendable { func cancel() { guard !cancelled else { return } cancelled = true + sendTimeout?.cancel() + sendTimeout = nil + sendGeneration = nil outbound.removeAll() _ = pendingSelectionOutput.take() connection.cancel() @@ -1388,10 +1624,18 @@ private final class WebRemoteClientConnection: @unchecked Sendable { encrypted = true } - func send(_ data: Data) { - guard !cancelled else { return } - outbound.enqueueMessage(data) + /// Queue ordinary data; returns false and records a bounded error notice + /// when the response is too large or the queue is temporarily full. + @discardableResult + func send(_ data: Data) -> Bool { + guard !cancelled else { return false } + guard outbound.enqueueMessage(data) else { + outbound.rejectMessage(data) + sendNextIfNeeded() + return false + } sendNextIfNeeded() + return true } func sendTerminalOutput(_ data: Data, pane: String) -> Bool { @@ -1446,12 +1690,24 @@ private final class WebRemoteClientConnection: @unchecked Sendable { metadata: [metadata] ) sendInFlight = true + let generation = UUID() + sendGeneration = generation + let timeout = DispatchWorkItem { [weak self] in + guard let self, self.sendGeneration == generation, self.sendInFlight else { return } + self.cancel() + } + sendTimeout?.cancel() + sendTimeout = timeout + sendQueue?.asyncAfter(deadline: .now() + 30, execute: timeout) connection.send( content: wireData, contentContext: context, isComplete: true, completion: .contentProcessed { [weak self] error in - guard let self else { return } + guard let self, !self.cancelled, self.sendGeneration == generation else { return } + self.sendTimeout?.cancel() + self.sendTimeout = nil + self.sendGeneration = nil self.sendInFlight = false if let error { webRemoteLogger.error("WebSocket send failed: \(error.localizedDescription, privacy: .public)") diff --git a/Glint/Workspace/Persistence.swift b/Glint/Workspace/Persistence.swift index 042bcc13..cb994e77 100644 --- a/Glint/Workspace/Persistence.swift +++ b/Glint/Workspace/Persistence.swift @@ -58,22 +58,45 @@ enum Persistence { return FileManager.default.fileExists(atPath: url.path) } - /// Path of a corrupt state.json that we could NOT move aside (disk full, - /// permissions). save() refuses to write over it so the only copy of the - /// user's data is never clobbered. Session-scoped: cleared on next launch. + /// A temporary read failure never authorizes replacing the saved session. + /// Only a successful explicit load can adopt it and permit later saves. + private static var unreadablePaths = Set() + + /// Path of a confirmed corrupt state.json whose quarantine failed. private static var corruptUnmovablePath: String? - /// Returns nil both for "no saved state" (fresh install) and "state was - /// unreadable" — but the two paths differ in side effects: an unreadable - /// file is moved aside (never deleted or overwritten) so a decode bug or - /// half-written file can't silently destroy the user's workspaces. Before - /// quarantining we try to surgically strip a single bad pane entry so it - /// costs only that pane, not every workspace. + /// Whether startup must offer recovery rather than silently saving a fresh session. + static var requiresLoadRetry: Bool { + guard let url = fileURL else { return false } + return unreadablePaths.contains(url.path) || corruptUnmovablePath == url.path + } + + /// Load the application snapshot; no parameters; returns nil if absent or + /// unavailable. I/O failures keep the original in place and block saves; + /// only confirmed undecodable data is quarantined after repair attempts. static func load() -> PersistedState? { guard let url = fileURL else { return nil } - guard let data = try? Data(contentsOf: url) else { return nil } + return load(from: url) + } + + /// Load a saved snapshot; url is its disk location; returns decoded state or nil on missing/unreadable data. + static func load(from url: URL) -> PersistedState? { + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + if isMissingOriginal(at: url, readError: error) { + clearRecoveryBlock(at: url) + } else { + unreadablePaths.insert(url.path) + NSLog("[glint] couldn't read \(fileName): \(error); leaving it in place and blocking saves until it is reloaded") + } + return nil + } do { - return try JSONDecoder().decode(PersistedState.self, from: data) + let restored = try JSONDecoder().decode(PersistedState.self, from: data) + clearRecoveryBlock(at: url) + return restored } catch { // Progressive recovery before quarantining. Two stages, each // feeding the next so a file with mixed damage still recovers the @@ -89,6 +112,7 @@ enum Persistence { let repaired = Self.stripBadWorkspaces(from: paneFixed) ?? paneFixed if repaired != data, let state = try? JSONDecoder().decode(PersistedState.self, from: repaired) { + clearRecoveryBlock(at: url) NSLog("[glint] decoded \(fileName) after stripping undecodable pane(s)/workspace(s); persisting the repaired copy") do { try repaired.write(to: url, options: [.atomic]) @@ -109,28 +133,85 @@ enum Persistence { return state } - let stamp = Int(Date().timeIntervalSince1970) - let backup = url.deletingLastPathComponent() - .appendingPathComponent("\(fileName).corrupt-\(stamp)") - do { - try FileManager.default.moveItem(at: url, to: backup) - NSLog("[glint] failed to decode \(fileName): \(error); moved it aside to \(backup.lastPathComponent) and starting fresh") - } catch { - // Couldn't move it aside either — remember the path so save() - // won't overwrite the only copy. The original stays put for - // the user to recover manually; we start fresh in memory. - corruptUnmovablePath = url.path - NSLog("[glint] \(fileName) couldn't be moved aside to \(backup.lastPathComponent); refusing to overwrite — original kept at \(url.path), starting fresh") - } + NSLog("[glint] failed to decode \(fileName): \(error); preserving it before starting fresh") + unreadablePaths.remove(url.path) + _ = preserveOriginal(at: url) return nil } } + /// Distinguish a genuinely absent entry from a missing symlink target; + /// inputs are its URL and read error; returns true only for missing entries. + private static func isMissingOriginal(at url: URL, readError: Error) -> Bool { + let failure = readError as NSError + guard failure.domain == NSCocoaErrorDomain, + failure.code == NSFileReadNoSuchFileError || failure.code == NSFileNoSuchFileError else { return false } + do { + _ = try FileManager.default.attributesOfItem(atPath: url.path) + return false + } catch { + let lookup = error as NSError + return lookup.domain == NSCocoaErrorDomain + && (lookup.code == NSFileReadNoSuchFileError || lookup.code == NSFileNoSuchFileError) + } + } + + /// Permit saves after adopting or confirming absence of a saved session; + /// url identifies the snapshot; returns nothing. + private static func clearRecoveryBlock(at url: URL) { + unreadablePaths.remove(url.path) + if corruptUnmovablePath == url.path { corruptUnmovablePath = nil } + } + + /// Move an unreadable original to a unique sibling; url is its location; returns whether saving may safely proceed. + private static func preserveOriginal(at url: URL) -> Bool { + let backup = url.deletingLastPathComponent() + .appendingPathComponent("\(fileName).corrupt-\(UUID().uuidString)") + do { + try FileManager.default.moveItem(at: url, to: backup) + if corruptUnmovablePath == url.path { corruptUnmovablePath = nil } + NSLog("[glint] preserved \(fileName) at \(backup.lastPathComponent)") + return true + } catch { + let failure = error as NSError + if failure.domain == NSCocoaErrorDomain, + failure.code == NSFileNoSuchFileError || failure.code == NSFileReadNoSuchFileError { + if corruptUnmovablePath == url.path { corruptUnmovablePath = nil } + return true + } + corruptUnmovablePath = url.path + NSLog("[glint] couldn't preserve \(fileName) (\(error)); refusing to overwrite the original") + return false + } + } + + /// Save the snapshot to the application path; state is the current model; returns no value. static func save(_ state: PersistedState) { guard let url = fileURL else { return } + save(state, to: url) + } + + /// Save a snapshot; state and destination url are inputs; returns no value, preserving unreadable originals. + static func save(_ state: PersistedState, to url: URL) { + guard !unreadablePaths.contains(url.path) else { return } if corruptUnmovablePath == url.path { - NSLog("[glint] skipping save: \(fileName) is the corrupt file we couldn't move aside; not overwriting the user's data") - return + do { + let data = try Data(contentsOf: url) + let paneFixed = stripBadPanes(from: data) ?? data + let repaired = stripBadWorkspaces(from: paneFixed) ?? paneFixed + if (try? JSONDecoder().decode(PersistedState.self, from: repaired)) != nil { + unreadablePaths.insert(url.path) + NSLog("[glint] \(fileName) is now recoverable; leaving it in place until the session is reloaded") + return + } + guard preserveOriginal(at: url) else { return } + } catch { + guard isMissingOriginal(at: url, readError: error) else { + unreadablePaths.insert(url.path) + return + } + clearRecoveryBlock(at: url) + } } let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .sortedKeys] diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index 70864df4..a5b09a6b 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -1756,6 +1756,8 @@ final class WorkspaceStore: ObservableObject { self.init(activity: PaneActivityStore()) } + /// Load the initial workspace snapshot and wire live updates; activity owns + /// pane status and externalControlNow supplies the clock; initializes the store. init(activity: PaneActivityStore, externalControlNow: @escaping () -> Date = Date.init) { self.externalControlNow = externalControlNow self.activity = activity @@ -1780,7 +1782,12 @@ final class WorkspaceStore: ObservableObject { // Drain paths Launch Services handed us before the store existed (cold // launch with a folder/file/glint:// URL). No-op when nothing's pending; // AppDelegate.deliver also calls flush() for warm-launch opens. - DispatchQueue.main.async { AppDelegate.flush() } + // Recovery needs AppKit ready and must precede pending URL opens that + // could otherwise create temporary workspaces before restoration. + DispatchQueue.main.async { [weak self] in + self?.offerSavedSessionRecovery() + AppDelegate.flush() + } // Reconcile the zsh-side inline-suggestion install on every launch: // first-ever launch installs from the current default; subsequent @@ -1844,7 +1851,9 @@ final class WorkspaceStore: ObservableObject { ScrollbackArchive.fileID(forPaneKey: "\(ws.id.uuidString):\(paneID.value)")) } } - ScrollbackArchive.prune(keeping: liveScrollbackIDs) + if !Persistence.requiresLoadRetry { + ScrollbackArchive.prune(keeping: liveScrollbackIDs) + } // Block-based observers hold their tokens in `observerTokens`; the // store currently lives as long as the app, but if it's ever torn @@ -1863,7 +1872,7 @@ final class WorkspaceStore: ObservableObject { guard let self else { return } self.clearDockBadge() self.captureCwdsFromLiveSurfaces() - self.flushScrollback() + self.flushScrollback(force: true) self.persist() } ScrollbackArchive.drain() @@ -2149,13 +2158,9 @@ final class WorkspaceStore: ObservableObject { } } - /// Snapshot every live surface's cwd back into the workspace model and - /// refresh the per-pane foreground process name. Called periodically, on - /// app exit, and whenever ghostty pushes a PWD update via its action - /// callback. - /// Persist every live pane's recorded terminal output. No-op when the - /// feature is off. Each view only writes when it has new bytes. - func flushScrollback() { + /// Persist live terminal grids when enabled; force bypasses the daily dirty + /// optimization for final snapshots; returns no value and queues archive I/O. + func flushScrollback(force: Bool = false) { guard restoreTerminalScrollback else { return } for view in surfaceViews.values { // Mark dirty when the pane's foreground pid changed since the last @@ -2164,7 +2169,7 @@ final class WorkspaceStore: ObservableObject { // the grid clearly evolved. Cheap (a single sysctl read; the cwd // sweep just did the same one) and idempotent. view.noteForegroundPidForScrollback() - view.flushScrollbackToDisk() + view.flushScrollbackToDisk(force: force) } } @@ -2672,6 +2677,73 @@ final class WorkspaceStore: ObservableObject { return WorkspacePaneKey(workspace: ws, pane: PaneID(value: seq)) } + /// Offer an explicit retry before replacing a temporary startup session; + /// no parameters; returns without changing terminals or save protection on cancellation. + private func offerSavedSessionRecovery() { + var temporarySessionChanged = false + while Persistence.requiresLoadRetry { + let workspaceIDs = Set(workspaces.map { $0.id }) + let paneKeys = Set(workspaces.flatMap { workspace in + workspace.panes.keys.map { WorkspacePaneKey(workspace: workspace.id, pane: $0) } + }) + let identities = surfaceViews.mapValues { ObjectIdentifier($0) } + let busyPanes = Set(paneKeys.filter { paneNeedsCloseConfirmation($0) }) + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "Saved Session Unavailable") + alert.informativeText = String(localized: "Your saved session is still on disk. Retry will restore it, replacing this temporary workspace session and closing its terminals. Continuing without saving keeps the original untouched; temporary workspace changes will not be saved.") + if temporarySessionChanged { + alert.informativeText += "\n\n" + String(localized: "The temporary session changed while the dialog was open. Review it before trying again.") + } + alert.addButton(withTitle: String(localized: "Retry and Restore")) + alert.addButton(withTitle: String(localized: "Continue Without Saving")) + guard alert.runModal() == .alertFirstButtonReturn else { return } + + let currentPaneKeys = Set(workspaces.flatMap { workspace in + workspace.panes.keys.map { WorkspacePaneKey(workspace: workspace.id, pane: $0) } + }) + let currentBusy = Set(currentPaneKeys.filter { paneNeedsCloseConfirmation($0) }) + guard workspaceIDs == Set(workspaces.map { $0.id }), paneKeys == currentPaneKeys, + identities == surfaceViews.mapValues({ ObjectIdentifier($0) }), + currentBusy.isSubset(of: busyPanes) else { + temporarySessionChanged = true + continue + } + // Read only after the modal and identity checks. A successful load + // permits saving, so adopt it synchronously without another modal + // or await that could let a temporary-state autosave run first. + guard let restored = Persistence.load() else { continue } + captureCwdsFromLiveSurfaces() + flushScrollback(force: true) + ScrollbackArchive.drain() + surfaceViews.removeAll() + paneAgentState.removeAll() + paneProcesses.removeAll() + pendingInitialInput.removeAll() + gitStatuses.removeAll() + webRemoteControlledPanes.removeAll() + clearExternalControlActivity() + clearDockBadge() + workspaces = restored.workspaces + let firstActiveID = workspaces.first(where: { !$0.archived })?.id ?? workspaces.first?.id + let shouldRestore = (UserDefaults.standard.object(forKey: "glint.restoreLastWorkspace") as? Bool) ?? true + let selected = shouldRestore ? (restored.selectedWorkspaceID ?? firstActiveID) : firstActiveID + selectedWorkspaceID = workspaces.first(where: { $0.id == selected })?.archived == true + ? firstActiveID : selected + sidebarCollapsed = restored.sidebarCollapsed + syncGitWatchers() + refreshAllGitStatuses() + let liveIDs = Set(workspaces.flatMap { workspace in + workspace.panes.keys.map { + ScrollbackArchive.fileID(forPaneKey: "\(workspace.id.uuidString):\($0.value)") + } + }) + ScrollbackArchive.prune(keeping: liveIDs) + return + } + } + func persist() { let state = PersistedState( workspaces: workspaces, @@ -3609,7 +3681,8 @@ final class WorkspaceStore: ObservableObject { /// the same code path as an app restart. Refuses to archive the last /// active workspace (the user must always have something visible). /// If the archived workspace was selected, advances to the next active - /// one in sidebar order. + /// one in sidebar order. Parameter id identifies the workspace; returns + /// no value and leaves live terminals untouched when confirmation cancels. func archiveWorkspace(_ id: UUID) { guard let idx = workspaces.firstIndex(where: { $0.id == id }), !workspaces[idx].archived else { return } @@ -3620,20 +3693,52 @@ final class WorkspaceStore: ObservableObject { return } + let busyPanes = workspaces[idx].panes.keys + .filter { paneNeedsCloseConfirmation(WorkspacePaneKey(workspace: id, pane: $0)) } + if !busyPanes.isEmpty, + !Self.confirmDestruction( + message: String(format: String(localized: "Archive “%@”?"), workspaces[idx].displayName), + informative: busyPanes.count == 1 + ? String(localized: "1 of its panes still has something running; archiving will terminate its processes.") + : String(format: String(localized: "%lld of its panes still have something running; archiving will terminate their processes."), Int64(busyPanes.count)), + confirmTitle: String(localized: "Archive Workspace"), + suppressionKey: "glint.suppressArchiveWorkspaceConfirm" + ) { + return + } + + // The modal runs a nested event loop: its target may have been closed, + // reordered or archived while the user was deciding. + guard let archiveIndex = workspaces.firstIndex(where: { $0.id == id }), + !workspaces[archiveIndex].archived else { return } + guard activeWorkspaces.count > 1 else { + NSSound.beep() + return + } + + for (key, view) in surfaceViews where key.workspace == id { + captureSurfaceState(from: view) + if restoreTerminalScrollback { + view.flushScrollbackToDisk(force: true) + } + } + ScrollbackArchive.drain() + // Drop live surfaces + side-state so the parked workspace doesn't // hold onto GPU/IOSurface memory. The Pane / SplitNode / scrollback // archive stay on disk so unarchive can rehydrate them. surfaceViews = surfaceViews.filter { $0.key.workspace != id } - clearDockBadges(for: workspaces[idx].panes.keys.map { WorkspacePaneKey(workspace: id, pane: $0) }) + clearDockBadges(for: workspaces[archiveIndex].panes.keys.map { WorkspacePaneKey(workspace: id, pane: $0) }) paneAgentState = paneAgentState.filter { $0.key.workspace != id } paneProcesses = paneProcesses.filter { $0.key.workspace != id } let wasSelected = selectedWorkspaceID == id - workspaces[idx].archived = true + workspaces[archiveIndex].archived = true if wasSelected, let next = activeWorkspaces.first { selectedWorkspaceID = next.id } + persist() } /// Lift a workspace back to the main sidebar list and select it. Surfaces diff --git a/GlintTests/ControlBridgeIntegrationTests.swift b/GlintTests/ControlBridgeIntegrationTests.swift index 6af9621f..5913a3b9 100644 --- a/GlintTests/ControlBridgeIntegrationTests.swift +++ b/GlintTests/ControlBridgeIntegrationTests.swift @@ -163,8 +163,13 @@ final class ControlBridgeIntegrationTests: XCTestCase { enabled = true bridge.start() - let oldConnection = try await Task.detached { try Self.exchange(connection, ["cmd": "list"]) }.value - XCTAssertEqual(oldConnection["error"] as? String, "disabled") + let oldConnectionClosed = await Task.detached { () -> Bool in + do { + _ = try Self.exchange(connection, ["cmd": "list"]) + return false + } catch { return true } + }.value + XCTAssertTrue(oldConnectionClosed, "Stop must shut down the old connection rather than keep it alive after restart") let freshConnection = try await Task.detached { try Self.connect(path) }.value defer { close(freshConnection) } let staleToken = try await Task.detached { @@ -176,6 +181,29 @@ final class ControlBridgeIntegrationTests: XCTestCase { XCTAssertEqual(store.selectedWorkspaceID, local.id) } + /// Reject bad credentials before entering a busy main queue; no inputs; returns after the private socket reply. + func testInvalidTokenDoesNotWaitForMainQueue() async throws { + let store = WorkspaceStore(activity: PaneActivityStore()) + let bridge = ControlBridge(runDirectory: try directory(), isEnabled: { true }) + bridge.start() + defer { bridge.stop(); withExtendedLifetime(store) {} } + let path = bridge.socketPath + let rejected = try await Task.detached { () throws -> Bool in + let entered = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + DispatchQueue.main.async { entered.signal(); release.wait() } + defer { release.signal() } + guard entered.wait(timeout: .now() + 3) == .success else { return false } + let fd = try Self.connect(path) + defer { close(fd) } + guard let response = try? Self.exchange(fd, ["cmd": "send-text", "pane": "invalid:1", + "text": "must-not-run", "token": "definitely-invalid-token"]) + else { return false } + return response["error"] as? String == "unauthorized" + }.value + XCTAssertTrue(rejected, "Invalid credentials must not wait on modal/layout work on the main queue") + } + func testOversizedSocketRequestReturnsExplicitError() async throws { let store = WorkspaceStore(activity: PaneActivityStore()) let bridge = ControlBridge(runDirectory: try directory(), isEnabled: { true }) diff --git a/GlintTests/PersistenceRecoveryTests.swift b/GlintTests/PersistenceRecoveryTests.swift index d4d39fc4..7f4d6e72 100644 --- a/GlintTests/PersistenceRecoveryTests.swift +++ b/GlintTests/PersistenceRecoveryTests.swift @@ -3,12 +3,147 @@ import XCTest /// Recovery logic in `Persistence.load()` is the only thing standing between a /// half-written state.json and losing every workspace. `load()`/`save()` read a -/// fixed Application Support path, so these tests exercise the pure -/// `Data -> Data?` recovery helpers directly — pinning them so a future +/// fixed Application Support path by default, so these tests use explicit +/// temporary URLs or pure `Data -> Data?` helpers — pinning them so a future /// refactor can't quietly regress the "one bad entry costs only that entry" /// guarantee. final class PersistenceRecoveryTests: XCTestCase { + /// Run disk tests in a private temporary directory; body receives state URL; returns no value or throws. + private func withTemporaryState(_ body: (URL) throws -> Void) throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("glint-state-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path) + try? FileManager.default.removeItem(at: dir) + } + try body(dir.appendingPathComponent("state.json")) + } + + /// Read preserved temporary files; url identifies the state directory; returns their bytes or throws. + private func preservedStateBytes(at url: URL) throws -> [Data] { + try FileManager.default.contentsOfDirectory(at: url.deletingLastPathComponent(), includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent != "state.json" } + .map { + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: $0.path) + return try Data(contentsOf: $0) + } + } + + /// Keep a read-failing original in place; no inputs; returns no value and asserts temporary saves cannot overwrite it. + func testUnreadableStateStaysInPlaceAndBlocksSave() throws { + try withTemporaryState { url in + let original = try stateData(workspaces: [Workspace.fresh(name: "Original", accentHex: "5E5CE6", symbol: "O")]) + try original.write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: url.path) + XCTAssertNil(Persistence.load(from: url)) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), "A temporary read error must not move state.json") + Persistence.save(.fresh, to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + XCTAssertEqual(try Data(contentsOf: url), original) + XCTAssertTrue(try preservedStateBytes(at: url).isEmpty, "I/O failures must not create corrupt backups") + } + } + + /// Resume saving only after explicitly adopting a recovered snapshot; no inputs; returns no value and asserts restored permissions alone cannot authorize replacement. + func testUnreadableStateRequiresSuccessfulReloadBeforeSave() throws { + try withTemporaryState { url in + let original = try stateData(workspaces: [Workspace.fresh(name: "Original", accentHex: "5E5CE6", symbol: "O")]) + try original.write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: url.path) + XCTAssertNil(Persistence.load(from: url)) + Persistence.save(.fresh, to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + Persistence.save(.fresh, to: url) + XCTAssertEqual(try Data(contentsOf: url), original, "A now-readable original still belongs to the saved session") + let restored = try XCTUnwrap(Persistence.load(from: url)) + XCTAssertEqual(restored.workspaces.first?.name, "Original") + let updated = PersistedState.fresh + Persistence.save(updated, to: url) + XCTAssertEqual(Persistence.load(from: url)?.workspaces.first?.id, updated.workspaces.first?.id) + XCTAssertTrue(try preservedStateBytes(at: url).isEmpty) + } + } + + /// Keep an original protected across repeated transient reads; no inputs; returns no value and asserts recovery is an explicit read. + func testRepeatedReadFailuresDoNotQuarantineState() throws { + try withTemporaryState { url in + let original = try stateData(workspaces: [Workspace.fresh(name: "Original", accentHex: "5E5CE6", symbol: "O")]) + try original.write(to: url) + for _ in 0..<3 { + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: url.path) + XCTAssertNil(Persistence.load(from: url)) + Persistence.save(.fresh, to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + XCTAssertEqual(try Data(contentsOf: url), original) + } + XCTAssertEqual(Persistence.load(from: url)?.workspaces.first?.name, "Original") + XCTAssertTrue(try preservedStateBytes(at: url).isEmpty) + } + } + + /// Retry failed corrupt-file quarantine after permissions recover; no inputs; returns no value and asserts saving resumes safely. + func testCorruptBackupFailureCanRecoverOnLaterSave() throws { + try withTemporaryState { url in + let original = Data("not-json".utf8) + try original.write(to: url) + let parent = url.deletingLastPathComponent() + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: parent.path) + XCTAssertNil(Persistence.load(from: url)) + Persistence.save(.fresh, to: url) + XCTAssertEqual(try Data(contentsOf: url), original) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: parent.path) + Persistence.save(.fresh, to: url) + XCTAssertTrue(try preservedStateBytes(at: url).contains(original)) + XCTAssertNotNil(Persistence.load(from: url)) + } + } + + /// Do not quarantine a valid file repaired after a failed backup; no inputs; returns no value and asserts save waits for adoption. + func testRepairedCorruptOriginalMustReloadBeforeSave() throws { + try withTemporaryState { url in + try Data("not-json".utf8).write(to: url) + let parent = url.deletingLastPathComponent() + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: parent.path) + XCTAssertNil(Persistence.load(from: url)) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: parent.path) + let repaired = try stateData(workspaces: [Workspace.fresh(name: "Repaired", accentHex: "5E5CE6", symbol: "R")]) + try repaired.write(to: url) + Persistence.save(.fresh, to: url) + XCTAssertEqual(try Data(contentsOf: url), repaired, "A valid repaired original must not be moved or replaced") + XCTAssertTrue(try preservedStateBytes(at: url).isEmpty) + XCTAssertEqual(Persistence.load(from: url)?.workspaces.first?.name, "Repaired") + } + } + + /// Allow fresh installations to save; no inputs; returns no value and asserts missing files create no quarantine. + func testMissingStateAllowsSaveWithoutBackup() throws { + try withTemporaryState { url in + XCTAssertNil(Persistence.load(from: url)) + Persistence.save(.fresh, to: url) + XCTAssertNotNil(Persistence.load(from: url)) + XCTAssertTrue(try preservedStateBytes(at: url).isEmpty) + } + } + + /// Leave a temporarily unavailable symlink target untouched; no inputs; returns no value and asserts later reads restore its session. + func testDanglingStateSymlinkRemainsInPlace() throws { + try withTemporaryState { url in + let target = url.deletingLastPathComponent().appendingPathComponent("temporarily-unavailable.json") + try FileManager.default.createSymbolicLink(at: url, withDestinationURL: target) + XCTAssertNil(Persistence.load(from: url)) + Persistence.save(.fresh, to: url) + let link = try? FileManager.default.destinationOfSymbolicLink(atPath: url.path) + XCTAssertEqual(link, target.path, "Missing targets must not cause the original symlink to be replaced") + let original = try stateData(workspaces: [Workspace.fresh(name: "Returned", accentHex: "5E5CE6", symbol: "R")]) + try original.write(to: target) + XCTAssertEqual(Persistence.load(from: url)?.workspaces.first?.name, "Returned") + let backups = try FileManager.default.contentsOfDirectory(at: url.deletingLastPathComponent(), includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.hasPrefix("state.json.corrupt-") } + XCTAssertTrue(backups.isEmpty) + } + } + /// Encode a fixture without disk access; workspaces and selection are inputs; returns JSON data. private func stateData(workspaces: [Workspace], selectedWorkspaceID: UUID? = nil) throws -> Data { let state = PersistedState(workspaces: workspaces, diff --git a/GlintTests/ReviewDiffParsingTests.swift b/GlintTests/ReviewDiffParsingTests.swift index 0c4f182e..2371418e 100644 --- a/GlintTests/ReviewDiffParsingTests.swift +++ b/GlintTests/ReviewDiffParsingTests.swift @@ -14,6 +14,9 @@ final class ReviewDiffParsingTests: XCTestCase { private struct NonLocalDiffRunner: GitRunner { /// Return NUL-delimited fixture output. Parameters: Git args, cwd and timeout. Returns a successful result. func run(_ args: [String], cwd: String?, timeout: GitTimeout) async throws -> GitResult { + if args.first == "rev-parse" { + return GitResult(exitCode: 0, stdout: String(repeating: "a", count: 40) + "\n", stderr: "") + } if args.contains("--name-status") { return GitResult(exitCode: 0, stdout: "M\0file.txt\0", stderr: "") } @@ -29,6 +32,69 @@ final class ReviewDiffParsingTests: XCTestCase { } } + private struct FailedDiffRunner: GitRunner { + let failAt: String + + /// Simulate one failed Git step. Parameters: argv, cwd, timeout. Returns fixture output or failure. + func run(_ args: [String], cwd: String?, timeout: GitTimeout) async throws -> GitResult { + if args.contains(failAt) { + return GitResult(exitCode: 128, stdout: "", stderr: "fixture failure") + } + return try await NonLocalDiffRunner().run(args, cwd: cwd, timeout: timeout) + } + + /// Supply empty untracked counts. Parameters: repository and paths. Returns an empty map. + func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] { [:] } + } + + /// Reject failed tracked comparisons. Parameters: none. Returns nothing; asserts both tracked list reads. + func testChangedFilesThrowsForFailedGitRead() async { + for step in ["--name-status", "--numstat"] { + do { + _ = try await GitService(runner: FailedDiffRunner(failAt: step)) + .changedFiles(repo: "/unused", scope: .workingTree) + XCTFail("Expected Git failure for \(step)") + } catch { } + } + } + + /// Keep tracked changes when only untracked enumeration fails; no inputs; asserts partial results and warning. + @MainActor + func testUntrackedFailureKeepsTrackedChangesAndWarns() async throws { + let runner = FailedDiffRunner(failAt: "ls-files") + let result = try await GitService(runner: runner).reviewChanges(repo: "/unused", scope: .workingTree) + XCTAssertEqual(result.files.map(\.path), ["file.txt"]) + XCTAssertTrue(result.untrackedWarning) + XCTAssertEqual(result.baseline, String(repeating: "a", count: 40)) + let model = ReviewModel(repo: "/unused", title: "Test", scopes: [.workingTree], runner: runner) + await model.reload() + XCTAssertEqual(model.files.map(\.path), ["file.txt"]) + XCTAssertNotNil(model.filesWarning) + XCTAssertNil(model.filesError) + XCTAssertEqual(model.selected?.path, "file.txt") + } + + /// Expose Git failures in Review rather than empty content. Parameters: none. Returns nothing; asserts model state. + @MainActor + func testReviewPublishesListAndDiffErrors() async { + let model = ReviewModel(repo: "/unused", title: "Test", scopes: [.workingTree], + runner: FailedDiffRunner(failAt: "--name-status")) + model.files = [file("stale.txt")] + model.selected = model.files.first + await model.reload() + XCTAssertNotNil(model.filesError) + XCTAssertTrue(model.files.isEmpty) + XCTAssertNil(model.selected) + XCTAssertFalse(model.loadingFiles) + + let diffModel = ReviewModel(repo: "/unused", title: "Test", scopes: [.workingTree], + runner: FailedDiffRunner(failAt: "--unified=1000000")) + await diffModel.select(file("file.txt")) + XCTAssertNotNil(diffModel.diffError) + XCTAssertTrue(diffModel.diffText.isEmpty) + XCTAssertFalse(diffModel.loadingDiff) + } + /// Preserve literal path bytes in NUL records. Parameters: none. Returns nothing; asserts counts and kinds. func testMergeNulRecordsPreservesSpecialFilenames() { let paths = ["中文.txt", "tab\tfile.txt", "line\nfile.txt", "quote\"file.txt"] @@ -269,7 +335,7 @@ rename to n defer { try? FileManager.default.removeItem(at: repo) } try Data("local collision".utf8).write(to: repo.appendingPathComponent("file.txt")) - let files = await GitService(runner: NonLocalDiffRunner()) + let files = try await GitService(runner: NonLocalDiffRunner()) .changedFiles(repo: repo.path, scope: .workingTree) XCTAssertEqual(files.count, 1) diff --git a/GlintTests/WebRemoteServerIntegrationTests.swift b/GlintTests/WebRemoteServerIntegrationTests.swift index 5d1479ec..6ae9e6b8 100644 --- a/GlintTests/WebRemoteServerIntegrationTests.swift +++ b/GlintTests/WebRemoteServerIntegrationTests.swift @@ -1,4 +1,5 @@ import CryptoKit +import Darwin import Network import XCTest @testable import Glint @@ -26,7 +27,7 @@ final class WebRemoteServerIntegrationTests: XCTestCase { testDefaults = try XCTUnwrap(UserDefaults(suiteName: defaultsName)) testDefaults.set(Int(try availableHTTPPort()), forKey: "glint.webRemoteHTTPPort") interfaceAddress.set(nil) - server = WebRemoteServer(defaults: testDefaults, interfaceRetryInterval: 0.2) { [interfaceAddress] key in + server = WebRemoteServer(defaults: testDefaults, interfaceRetryInterval: 0.2, httpRequestTimeout: 1) { [interfaceAddress] key in key == "test-interface" ? interfaceAddress.get() : WebRemoteListenTarget.bindAddress(for: key) } urlSession = URLSession(configuration: .ephemeral) @@ -116,6 +117,53 @@ final class WebRemoteServerIntegrationTests: XCTestCase { XCTAssertNotNil(http.value(forHTTPHeaderField: "Content-Length")) } + /// Silent HTTP peers must release their slot; takes no arguments; returns after observing EOF. + func testHTTPSilentHeadersExpire() async throws { + let peer = try connectHTTPPeer(receiveTimeout: 2) + defer { close(peer) } + let closed = await httpPeerClosed(peer) + XCTAssertTrue(closed, "An accepted HTTP connection must have a header deadline") + } + + /// Partial header progress must not extend the request deadline; takes no arguments; returns after observing EOF. + func testHTTPPartialHeadersUseFixedDeadline() async throws { + let peer = try connectHTTPPeer(receiveTimeout: 2) + defer { close(peer) } + let start = Date() + try sendHTTPBytes("GET / HTTP/1.1\r\nX: ", to: peer) + try await Task.sleep(nanoseconds: 600_000_000) + try sendHTTPBytes("a", to: peer) + let closed = await httpPeerClosed(peer) + XCTAssertTrue(closed) + XCTAssertLessThan(Date().timeIntervalSince(start), 1.5, + "Progress must not turn the fixed deadline into an idle timeout") + } + + /// Pending HTTP connections must obey the admission cap; takes no arguments; returns after the overflow peer closes. + func testHTTPRejectsConnectionsBeyondPendingLimit() async throws { + var peers: [Int32] = [] + defer { peers.forEach { close($0) } } + for _ in 0.. Int32 { + let origin = try XCTUnwrap(httpOrigin) + let components = try XCTUnwrap(URLComponents(string: origin)) + let port = try XCTUnwrap(components.port) + let peer = socket(AF_INET, SOCK_STREAM, 0) + guard peer >= 0 else { throw POSIXError(.EMFILE) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = UInt16(port).bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(peer, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { close(peer); throw POSIXError(.ECONNREFUSED) } + var timeout = timeval(tv_sec: Int(receiveTimeout), + tv_usec: Int32((receiveTimeout - floor(receiveTimeout)) * 1_000_000)) + _ = setsockopt(peer, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var noSignal: Int32 = 1 + _ = setsockopt(peer, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + return peer + } + + /// Send partial HTTP data; inputs are text and descriptor; returns after sending or throws. + private func sendHTTPBytes(_ text: String, to peer: Int32) throws { + let bytes = Data(text.utf8) + let count = bytes.withUnsafeBytes { Darwin.send(peer, $0.baseAddress, $0.count, 0) } + guard count == bytes.count else { throw POSIXError(.EPIPE) } + } + + /// Wait for bounded EOF/reset off the test executor; input is the peer descriptor; returns whether it closed. + private func httpPeerClosed(_ peer: Int32) async -> Bool { + await Task.detached { + var byte: UInt8 = 0 + let count = recv(peer, &byte, 1, 0) + return count == 0 || (count < 0 && (errno == ECONNRESET || errno == ENOTCONN)) + }.value + } + /// Reserve both sockets together before choosing the pair. NWListener binds /// them after these probes close; no persisted production ports are reused. private func availableHTTPPort() throws -> UInt16 { diff --git a/project.yml b/project.yml index 536a24db..f6b7a5b3 100644 --- a/project.yml +++ b/project.yml @@ -17,7 +17,7 @@ settings: packages: Sparkle: url: https://github.com/sparkle-project/Sparkle.git - from: "2.6.4" + exactVersion: "2.6.4" targets: Glint: diff --git a/scripts/test-control-lifecycle.mjs b/scripts/test-control-lifecycle.mjs new file mode 100644 index 00000000..977a1d34 --- /dev/null +++ b/scripts/test-control-lifecycle.mjs @@ -0,0 +1,155 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +// Compile the entire bridge with a temporary HOME substituted only in the test +// copy. Exercise its real Unix socket, accept loop and stop/start lifecycle. +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'glint-control-test-')); +const support = String.raw` +import Foundation +import Darwin +enum ControlTestGate { + static let lock = NSLock() + static var before: (() -> Void)? + static var after: (() -> Void)? + /// Install deterministic queue gates; inputs are before/after callbacks; returns nothing. + static func set(before: (() -> Void)?, after: (() -> Void)?) { + lock.lock(); self.before = before; self.after = after; lock.unlock() + } + /// Pause before the actual main-thread hop; no inputs or return value. + static func invokeBefore() { + lock.lock(); let block = before; lock.unlock(); block?() + } + /// Observe completion of the actual main-thread hop; no inputs or return value. + static func invokeAfter() { + lock.lock(); let block = after; lock.unlock(); block?() + } +} +final class WorkspaceStore { + static let current: WorkspaceStore? = WorkspaceStore() + var injections = 0 + var externalControlEnabled = true + enum ControlGridResult { case success(Data), failure(String) } + /// List stub panes; no inputs; returns an empty list. + func controlListPanes() -> [[String: Any]] { [] } + /// Focus a stub pane; pane is its identifier; returns no error. + func controlFocus(pane: String) -> String? { nil } + /// Record a command; arguments match production; returns no error. + func controlSendText(pane: String, text: String, enter: Bool) -> String? { injections += 1; return nil } + /// Accept stub keys; arguments match production; returns no error. + func controlSendKeys(pane: String, keys: [String]) -> String? { nil } + /// Stand in for terminal-grid export; arguments match production; returns a safe unavailable result. + func controlReadGrid(pane: String, scrollback: Bool, maxLines: Int) -> ControlGridResult { .failure("pane-not-ready") } +} +enum GhosttySurfaceView { + /// Stand in for grid decoding; inputs match production; returns no text without a real engine. + static func controlText(fromRenderGrid data: Data, scrollback: Bool, maxLines: Int) -> String? { nil } +} +extension ControlBridge { + /// Construct an isolated bridge; no inputs; returns a bridge without starting it. + static func isolated() -> ControlBridge { ControlBridge() } +} +`; +const main = String.raw` +import Foundation +import Darwin +/// Fail a regression; condition and label are inputs; returns nothing or exits. +func check(_ value: Bool, _ label: String) { + guard value else { print("FAIL: \(label)"); exit(1) } + print("PASS: \(label)") +} +/// Open the temporary Unix socket; path is the socket path; returns its owned descriptor. +func connectSocket(_ path: String) -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + path.withCString { src in + withUnsafeMutableBytes(of: &addr.sun_path) { dst in + _ = strlcpy(dst.baseAddress!.assumingMemoryBound(to: CChar.self), src, dst.count) + } + } + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) } + } + check(result == 0, "connect temporary socket") + var flag: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &flag, socklen_t(MemoryLayout.size)) + var timeout = timeval(tv_sec: 2, tv_usec: 0) + _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + return fd +} +/// Send one request and read a response; fd and token are inputs; returns parsed JSON or nil on closed connection. +func request(_ fd: Int32, _ token: String) -> [String: Any]? { + let data = try! JSONSerialization.data(withJSONObject: ["cmd": "send-text", "pane": "test:1", "text": "test", "token": token]) + Data([10]) + let sent = data.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + guard sent > 0 else { return nil } + var bytes = [UInt8](repeating: 0, count: 4096) + let count = Darwin.read(fd, &bytes, bytes.count) + guard count > 0 else { return nil } + return (try? JSONSerialization.jsonObject(with: Data(bytes.prefix(count)))) as? [String: Any] +} +let bridge = ControlBridge.isolated() +DispatchQueue.global().async { + DispatchQueue.main.sync { bridge.start() } + let oldToken = try! String(contentsOfFile: bridge.tokenPath, encoding: .utf8) + let fd = connectSocket(bridge.socketPath) + check(request(fd, oldToken)?["ok"] as? Bool == true, "active command succeeds") + DispatchQueue.main.sync { bridge.stop() } + check(request(fd, oldToken)?["ok"] as? Bool != true, "stop revokes already accepted connection") + close(fd) + DispatchQueue.main.sync { bridge.start() } + let newToken = try! String(contentsOfFile: bridge.tokenPath, encoding: .utf8) + check(newToken != oldToken, "restart rotates token") + let next = connectSocket(bridge.socketPath) + check(request(next, oldToken)?["error"] as? String == "unauthorized", "new connection rejects old token") + check(request(next, newToken)?["ok"] as? Bool == true, "new token succeeds") + let mainBlocked = DispatchSemaphore(value: 0) + let unblockMain = DispatchSemaphore(value: 0) + DispatchQueue.main.async { mainBlocked.signal(); unblockMain.wait() } + check(mainBlocked.wait(timeout: .now() + 3) == .success, "main queue is deliberately occupied") + let unauthorized = connectSocket(bridge.socketPath) + let rejected = request(unauthorized, "not-the-token") + unblockMain.signal() + close(unauthorized) + check(rejected?["error"] as? String == "unauthorized", "invalid token is rejected without waiting for the main queue") + let reachedGate = DispatchSemaphore(value: 0) + let resumeRequest = DispatchSemaphore(value: 0) + let handled = DispatchSemaphore(value: 0) + let requestFinished = DispatchSemaphore(value: 0) + ControlTestGate.set(before: { reachedGate.signal(); resumeRequest.wait() }, after: { handled.signal() }) + let racing = connectSocket(bridge.socketPath) + DispatchQueue.global().async { _ = request(racing, newToken); requestFinished.signal() } + check(reachedGate.wait(timeout: .now() + 3) == .success, "request waits before main-thread execution") + DispatchQueue.main.sync { bridge.stop() } + resumeRequest.signal() + check(handled.wait(timeout: .now() + 3) == .success, "queued request finishes after revocation") + check(requestFinished.wait(timeout: .now() + 3) == .success, "revoked socket unblocks its peer") + ControlTestGate.set(before: nil, after: nil) + close(racing) + close(next) + check(WorkspaceStore.current!.injections == 2, "only authorized live commands execute") + exit(0) +} +dispatchMain() +`; +try { + const source = fs.readFileSync(process.env.GLINT_CONTROL_SOURCE ?? path.join(root, 'Glint/Agent/ControlBridge.swift'), 'utf8') + .replaceAll('FileManager.default.homeDirectoryForCurrentUser', 'URL(fileURLWithPath: ProcessInfo.processInfo.environment["GLINT_TEST_HOME"]!)') + // Pause around the real main-thread hop without replacing its validation. + .replace(' var response = Self.errorResponse("unknown-cmd")', ' ControlTestGate.invokeBefore()\n var response = Self.errorResponse("unknown-cmd")') + .replace(' return response\n }', ' ControlTestGate.invokeAfter()\n return response\n }'); + fs.writeFileSync(path.join(dir, 'Bridge.swift'), source + support); + fs.writeFileSync(path.join(dir, 'main.swift'), main); + const build = spawnSync('swiftc', ['-swift-version', '5', '-module-cache-path', path.join(dir, 'cache'), path.join(dir, 'Bridge.swift'), path.join(root, 'Glint/Agent/SafeJSON.swift'), path.join(root, 'Shared/CLIInstallation.swift'), path.join(dir, 'main.swift'), '-o', path.join(dir, 'test')], { encoding: 'utf8', timeout: 60000 }); + process.stdout.write(build.stdout ?? ''); process.stderr.write(build.stderr ?? ''); + if (build.status !== 0) process.exitCode = 1; + else { + const run = spawnSync(path.join(dir, 'test'), [], { encoding: 'utf8', timeout: 20000, env: { ...process.env, GLINT_TEST_HOME: dir } }); + process.stdout.write(run.stdout ?? ''); process.stderr.write(run.stderr ?? ''); + process.exitCode = run.status ?? 1; + if (run.error) process.stderr.write(String(run.error)); + } +} finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/scripts/test-git-review.mjs b/scripts/test-git-review.mjs index f9050b66..c9a2affd 100644 --- a/scripts/test-git-review.mjs +++ b/scripts/test-git-review.mjs @@ -41,19 +41,23 @@ function check(name, body) { try { const service = readFileSync(join(root, 'Glint/Git/GitService.swift'), 'utf8'); const diff = readFileSync(join(root, 'Glint/Git/GitDiff.swift'), 'utf8'); + const review = readFileSync(join(root, 'Glint/Review/ReviewWindow.swift'), 'utf8'); + const reviewModel = review.slice(review.indexOf('@MainActor\nfinal class ReviewModel:'), review.indexOf('// MARK: - Root view')) + .replace('ReviewModel: ObservableObject', 'ReviewModel').replaceAll('@Published ', ''); // Compile the complete production diff extension; only the process runner and // unrelated application types are stand-ins, so command flags are exercised. const support = String.raw` import Foundation enum GitTimeout { case read } -struct GitResult { let stdout: String; let ok: Bool; let wasSignaled = false } +struct GitResult { let exitCode: Int32; let stdout: String; let stderr: String; var wasSignaled = false; var ok: Bool { exitCode == 0 } } +enum GitError: Error { case commandFailed(args: [String], exitCode: Int32, stderr: String); case launchFailed(String) } protocol GitRunner: Sendable { - func run(_ args: [String], cwd: String?) throws -> GitResult + func run(_ args: [String], cwd: String?, timeout: GitTimeout) async throws -> GitResult func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] } struct LocalGitRunner: GitRunner { /// Execute real Git. Parameters: argv and cwd. Returns decoded output and success. - func run(_ args: [String], cwd: String?) throws -> GitResult { + func run(_ args: [String], cwd: String?, timeout: GitTimeout) async throws -> GitResult { let process = Process(), output = Pipe(), errors = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = args @@ -62,24 +66,110 @@ struct LocalGitRunner: GitRunner { try process.run() let data = output.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() - return GitResult(stdout: String(decoding: data, as: UTF8.self), ok: process.terminationStatus == 0) + return GitResult(exitCode: process.terminationStatus, stdout: String(decoding: data, as: UTF8.self), + stderr: String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)) } /// Stub unused untracked counts. Parameters: repo and paths. Returns an empty map. func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] { [:] } } struct GitService { - let runner: any GitRunner = LocalGitRunner() - /// Forward production diff calls to Git. Parameters: argv, cwd, failure policy, timeout. Returns output. - func git(_ args: [String], cwd: String?, allowFailure: Bool, timeout: GitTimeout) async throws -> GitResult { - try runner.run(args, cwd: cwd) + var runner: any GitRunner = LocalGitRunner() + GIT_FUNCTION +} +struct FailureRunner: GitRunner { + let stage: String + /// Simulate Git transport/command failures; parameters are argv/cwd/timeout; returns fixture output or throws. + func run(_ args: [String], cwd: String?, timeout: GitTimeout) async throws -> GitResult { + if stage == "launch" { throw GitError.launchFailed("unavailable") } + if ["head-permission", "ref-permission"].contains(stage) { + if args.first == "rev-parse" { + return GitResult(exitCode: 1, stdout: "", stderr: stage == "head-permission" ? "permission denied" : "") + } + if args.first == "symbolic-ref" { return GitResult(exitCode: 0, stdout: "refs/heads/new\n", stderr: "") } + if args.first == "show-ref" { return GitResult(exitCode: 1, stdout: "", stderr: "permission denied") } + } + if args.contains(stage) || (stage == "signal" && args.contains("--numstat")) { + return GitResult(exitCode: stage == "signal" ? 0 : 128, stdout: "partial", stderr: "failed", wasSignaled: stage == "signal") + } + let out = args.contains("--name-status") ? "M\0file.txt\0" : args.contains("--numstat") ? "1\t1\tfile.txt\0" : args.contains("rev-parse") ? String(repeating: "a", count: 40) + "\n" : "" + return GitResult(exitCode: 0, stdout: out, stderr: "") + } + /// Stub untracked counters; parameters are repo/paths; returns no counts. + func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] { [:] } +} +final class RecoveringRunner: GitRunner, @unchecked Sendable { + private let lock = NSLock() + private var failure: String = "--name-status" + /// Change the fixture failure; parameter is its stage; returns nothing. + func setStage(_ stage: String) { lock.withLock { failure = stage } } + /// Delegate fixture output; parameters are argv/cwd/timeout; returns result or throws. + func run(_ args: [String], cwd: String?, timeout: GitTimeout) async throws -> GitResult { + let stage = lock.withLock { failure } + return try await FailureRunner(stage: stage).run(args, cwd: cwd, timeout: timeout) + } + /// Stub untracked counters; parameters are repo/paths; returns no counts. + func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] { [:] } +} +actor ReviewProbeRunner: GitRunner { + private var calls: [[String]] = [] + private var head = String(repeating: "a", count: 40) + private var blockNames = false + private var gate: CheckedContinuation? + private var observers: [CheckedContinuation] = [] + /// Record fixture reads with a controllable list gate; inputs mirror GitRunner; returns literal Git output. + func run(_ args: [String], cwd: String?, timeout: GitTimeout) async throws -> GitResult { + calls.append(args) + if args.first == "rev-parse" { return GitResult(exitCode: 0, stdout: head + "\n", stderr: "") } + if args.contains("--name-status") { + if blockNames { + blockNames = false + await withCheckedContinuation { gate = $0; observers.forEach { $0.resume() }; observers.removeAll() } + } + return GitResult(exitCode: 0, stdout: "M\0file.txt\0", stderr: "") + } + if args.contains("--numstat") { return GitResult(exitCode: 0, stdout: "1\t1\tfile.txt\0", stderr: "") } + if args.contains("--unified=1000000") { return GitResult(exitCode: 0, stdout: "@@ -1 +1 @@\n-old\n+new\n", stderr: "") } + return GitResult(exitCode: 0, stdout: "", stderr: "") } + /// Supply empty counts; inputs are repo and paths; returns no records. + func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] { [:] } + /// Count one observable Git operation; input is its argv marker; returns recorded call count. + func count(_ marker: String) -> Int { calls.filter { $0.contains(marker) }.count } + /// Read baseline arguments used for file diffs; no inputs; returns their complete argv. + func diffs() -> [[String]] { calls.filter { $0.contains("--unified=1000000") } } + /// Change the next HEAD result; input is a complete OID; returns nothing. + func setHead(_ oid: String) { head = oid } + /// Suspend the next names request; no inputs or return value. + func blockNextList() { blockNames = true } + /// Observe the names request actually suspended; no inputs; returns after reaching the gate. + func waitForList() async { + if gate != nil { return } + await withCheckedContinuation { observers.append($0) } + } + /// Resume the controlled list request; no inputs or return value. + func releaseList() { let old = gate; gate = nil; old?.resume() } +} +struct TreeNode { + /// Stand in for UI tree layout; parameter is changed files; returns placeholder nodes. + static func build(_ files: [GitFileChange]) -> [TreeNode] { files.map { _ in TreeNode() } } +} +struct CombinedGroup { let dir: String; let files: [GitFileChange] } +enum SyntaxLanguage { + case plain + /// Stand in for syntax choice; parameter is path; returns plain text. + static func from(path: String) -> SyntaxLanguage { .plain } +} +struct DiffDocument { + static let empty = DiffDocument(text: "", language: .plain) + let text: String + let language: SyntaxLanguage } `; const entry = String.raw` struct Row: Encodable { let path: String; let kind: String; let additions: Int; let deletions: Int; let binary: Bool; let diff: String } @main struct Regression { /// Drive production diff/quote functions. Parameters: command-line args. Returns JSON or shell token to stdout. - static func main() async throws { + @MainActor static func main() async throws { let args = CommandLine.arguments if args[1] == "quote" { print(SSHGitRunner.quoteRemoteCwd(args[2])); return } if args[1] == "parse" { @@ -90,20 +180,112 @@ struct Row: Encodable { let path: String; let kind: String; let additions: Int; let rows = files.values.sorted { $0.path < $1.path }.map { Row(path: $0.path, kind: $0.kind.rawValue, additions: $0.additions, deletions: $0.deletions, binary: $0.isBinary, diff: "") } print(String(decoding: try JSONEncoder().encode(rows), as: UTF8.self)); return } + if args[1] == "failure" { + let git = GitService(runner: FailureRunner(stage: args[2])) + do { + if args[2] == "diff" { + _ = try await git.fileDiff(repo: "unused", scope: .branch(base: "main"), + file: GitFileChange(path: "file.txt", kind: .modified, additions: 1, deletions: 1, isBinary: false)) + } else { _ = try await git.changedFiles(repo: "unused", scope: .workingTree) } + print("false") + } catch { print("true") } + return + } + if args[1] == "partial-model" { + let model = ReviewModel(repo: "unused", title: "Partial", scopes: [.workingTree], runner: FailureRunner(stage: "ls-files")) + await model.reload() + print(String(decoding: try JSONEncoder().encode([model.files.count == 1, model.filesError == nil, model.selected?.path == "file.txt", model.filesWarning != nil]), as: UTF8.self)) + return + } + if args[1] == "baseline-model" { + let runner = ReviewProbeRunner() + let model = ReviewModel(repo: "unused", title: "Baseline", scopes: [.workingTree], runner: runner) + await model.reload() + await model.select(model.files[0]) + let first = await runner.count("rev-parse") == 1 + await runner.setHead(String(repeating: "b", count: 40)) + await model.reload() + let second = await runner.count("rev-parse") == 2 + let diffs = await runner.diffs() + let pinned = diffs.count == 3 && diffs[0].contains(String(repeating: "a", count: 40)) && diffs[1].contains(String(repeating: "a", count: 40)) && diffs[2].contains(String(repeating: "b", count: 40)) + print(String(decoding: try JSONEncoder().encode([first, second, pinned]), as: UTF8.self)); return + } + if args[1] == "loading-model" { + let runner = ReviewProbeRunner() + let model = ReviewModel(repo: "unused", title: "Loading", scopes: [.workingTree], runner: runner) + await model.reload() + let old = model.files[0] + await runner.blockNextList() + let reload = Task { await model.reload() } + await runner.waitForList() + let loading = model.loadingFiles && model.diffText.isEmpty && model.selected == nil + let before = await runner.count("--unified=1000000") + await model.select(old) + let ignored = await runner.count("--unified=1000000") == before + await runner.releaseList() + await reload.value + let loaded = !model.loadingFiles && !model.loadingDiff && model.selected?.path == "file.txt" && !model.diffText.isEmpty + print(String(decoding: try JSONEncoder().encode([loading, ignored, loaded]), as: UTF8.self)); return + } + if args[1] == "overlap-model" { + let runner = ReviewProbeRunner() + let model = ReviewModel(repo: "unused", title: "Overlap", scopes: [.workingTree], runner: runner) + await runner.blockNextList() + let old = Task { await model.reload() } + await runner.waitForList() + await runner.setHead(String(repeating: "b", count: 40)) + await model.reload() + await runner.releaseList() + await old.value + await model.select(model.files[0]) + let diffs = await runner.diffs() + let newest = diffs.last?.contains(String(repeating: "b", count: 40)) == true + print(String(decoding: try JSONEncoder().encode([newest, !model.loadingFiles, !model.loadingDiff]), as: UTF8.self)); return + } + if args[1] == "snapshot-real" { + let git = GitService() + let first = try await git.reviewChanges(repo: args[2], scope: .workingTree) + _ = try await git.git(["add", "."], cwd: args[2], timeout: .read) + _ = try await git.git(["commit", "-qm", "advance HEAD"], cwd: args[2], timeout: .read) + let diff = try await git.fileDiff(repo: args[2], scope: .workingTree, file: first.files[0], baseline: first.baseline) + let second = try await git.reviewChanges(repo: args[2], scope: .workingTree) + print(String(decoding: try JSONEncoder().encode(["pinned": diff.contains("-before") && diff.contains("+after"), "reload": first.baseline != second.baseline && second.files.isEmpty]), as: UTF8.self)); return + } + if args[1] == "model" { + let runner = RecoveringRunner() + let model = ReviewModel(repo: "unused", title: "Review", scopes: [.workingTree], runner: runner) + let stale = GitFileChange(path: "stale", kind: .modified, additions: 1, deletions: 1, isBinary: false) + model.files = [stale]; model.selected = stale; model.diffText = "old diff" + await model.reload() + let failed = model.filesError != nil && model.files.isEmpty && model.selected == nil && !model.loadingFiles && model.diffText.isEmpty + runner.setStage("") + await model.reload() + let recovered = model.filesError == nil && model.files.count == 1 && model.selected?.path == "file.txt" && !model.loadingFiles + runner.setStage("diff") + await model.select(stale) + let diffFailed = model.diffError != nil && model.diffText.isEmpty && !model.loadingDiff + runner.setStage("") + await model.select(stale) + let diffRecovered = model.diffError == nil && !model.loadingDiff + print(String(decoding: try JSONEncoder().encode([failed, recovered, diffFailed, diffRecovered]), as: UTF8.self)) + return + } + do { let scope: DiffScope = args[1] == "working" ? .workingTree : .branch(base: args[3]) let git = GitService() var rows: [Row] = [] - for file in await git.changedFiles(repo: args[2], scope: scope) { + for file in try await git.changedFiles(repo: args[2], scope: scope) { rows.append(Row(path: file.path, kind: file.kind.rawValue, additions: file.additions, deletions: file.deletions, - binary: file.isBinary, diff: await git.fileDiff(repo: args[2], scope: scope, file: file))) + binary: file.isBinary, diff: try await git.fileDiff(repo: args[2], scope: scope, file: file))) } print(String(decoding: try JSONEncoder().encode(rows), as: UTF8.self)) + } catch { print("{\"error\":true}") } } } `; const swiftPath = join(scratch, 'Regression.swift'); const executable = join(scratch, 'regression'); - writeFileSync(swiftPath, support + diff + '\n' + extractFunction(service, 'func posixShellQuoted(') + writeFileSync(swiftPath, support.replace('GIT_FUNCTION', extractFunction(service, 'func git(_ args:')) + diff + '\n' + reviewModel + extractFunction(service, 'func posixShellQuoted(') + '\nenum SSHGitRunner {\n' + extractFunction(service, 'static func quoteRemoteCwd(') + '\n}\n' + entry); run('/usr/bin/swiftc', ['-parse-as-library', '-module-cache-path', join(scratch, 'cache'), swiftPath, '-o', executable]); @@ -117,6 +299,77 @@ struct Row: Encodable { let path: String; let kind: String; let additions: Int; assert.equal(rows.find(row => row.path === 'binary.dat').binary, true); }); + check('untracked enumeration failure keeps tracked changes available in Review', () => { + assert.deepEqual(JSON.parse(run(executable, ['partial-model'])), [true, true, true, true]); + }); + check('Review resolves a baseline once per reload and pins file selections to that OID', () => { + assert.deepEqual(JSON.parse(run(executable, ['baseline-model'])), [true, true, true]); + }); + check('Review refresh clears obsolete content and rejects old rows while the list is loading', () => { + assert.deepEqual(JSON.parse(run(executable, ['loading-model'])), [true, true, true]); + }); + check('late file-list loads cannot overwrite the newest comparison baseline', () => { + assert.deepEqual(JSON.parse(run(executable, ['overlap-model'])), [true, true, true]); + }); + const changing = join(scratch, 'changing'); + run('/usr/bin/git', ['init', '-q', changing]); + run('/usr/bin/git', ['-C', changing, 'config', 'user.name', 'Regression']); + run('/usr/bin/git', ['-C', changing, 'config', 'user.email', 'regression@example.invalid']); + writeFileSync(join(changing, 'file.txt'), 'before\n'); + run('/usr/bin/git', ['-C', changing, 'add', '.']); + run('/usr/bin/git', ['-C', changing, 'commit', '-qm', 'before']); + writeFileSync(join(changing, 'file.txt'), 'after\n'); + check('a real commit between listing and selection does not change the loaded baseline', () => { + assert.deepEqual(JSON.parse(run(executable, ['snapshot-real', changing])), { pinned: true, reload: true }); + }); + const unborn = join(scratch, 'unborn'); + run('/usr/bin/git', ['init', '-q', unborn]); + writeFileSync(join(unborn, 'first.txt'), 'first change\n'); + run('/usr/bin/git', ['-C', unborn, 'add', '.']); + writeFileSync(join(unborn, 'first.txt'), 'first change\nworking edit\n'); + check('initial repository includes staged files and subsequent working edits', () => { + const rows = JSON.parse(run(executable, ['working', unborn])); + assert.equal(rows.length, 1); + assert.equal(rows[0].path, 'first.txt'); + assert.equal(rows[0].kind, 'added'); + assert.equal(rows[0].additions, 2); + assert.match(rows[0].diff, /\+first change/); + assert.match(rows[0].diff, /\+working edit/); + }); + const sha256 = join(scratch, 'sha256'); + const sha256Init = spawnSync('/usr/bin/git', ['init', '-q', '--object-format=sha256', sha256], { encoding: 'utf8' }); + if (sha256Init.status === 0) { + writeFileSync(join(sha256, 'first.txt'), 'sha256 first change\n'); + run('/usr/bin/git', ['-C', sha256, 'add', '.']); + check('SHA-256 initial repository uses its own empty-tree hash', () => { + const rows = JSON.parse(run(executable, ['working', sha256])); + assert.equal(rows.length, 1); + assert.equal(rows[0].path, 'first.txt'); + assert.equal(rows[0].additions, 1); + assert.match(rows[0].diff, /\+sha256 first change/); + }); + } else { + console.log('SKIP SHA-256 repository: installed Git does not support this object format'); + } + const broken = join(scratch, 'broken'); + run('/usr/bin/git', ['init', '-q', broken]); + const branch = run('/usr/bin/git', ['-C', broken, 'symbolic-ref', 'HEAD']).trim(); + writeFileSync(join(broken, '.git', branch), 'f'.repeat(40) + '\n'); + check('broken existing HEAD is not treated as an unborn branch', () => { + assert.deepEqual(JSON.parse(run(executable, ['working', broken])), { error: true }); + }); + for (const stage of ['--name-status', '--numstat', 'diff', 'launch', 'signal', 'head-permission', 'ref-permission']) { + check(`Git failure is surfaced for ${stage}`, () => { + assert.equal(JSON.parse(run(executable, ['failure', stage])), true); + }); + } + check('Review model publishes list/diff errors, clears stale content and recovers on retry', () => { + assert.deepEqual(JSON.parse(run(executable, ['model'])), [true, true, true, true]); + }); + check('non-repository errors remain errors', () => { + assert.deepEqual(JSON.parse(run(executable, ['working', scratch])), { error: true }); + }); + const repo = join(scratch, 'repo'); run('/usr/bin/git', ['init', '-q', repo]); run('/usr/bin/git', ['-C', repo, 'config', 'user.name', 'Regression']); @@ -151,6 +404,19 @@ struct Row: Encodable { let path: String; let kind: String; let additions: Int; run('/usr/bin/git', ['-C', repo, 'add', '.']); run('/usr/bin/git', ['-C', repo, 'commit', '-qm', 'changes']); verifyScope('branch', ['branch', repo, base]); + check('invalid branch baseline produces an error', () => { + assert.deepEqual(JSON.parse(run(executable, ['branch', repo, 'does-not-exist'])), { error: true }); + }); + check('successful empty repository result remains empty', () => { + assert.deepEqual(JSON.parse(run(executable, ['working', repo])), []); + }); + writeFileSync(join(repo, 'untracked.txt'), 'fresh\n'); + check('no-index diff exit one remains a successful untracked diff', () => { + const rows = JSON.parse(run(executable, ['working', repo])); + assert.equal(rows.length, 1); + assert.equal(rows[0].kind, 'untracked'); + assert.match(rows[0].diff, /\+fresh/); + }); for (const shell of ['/bin/bash', '/bin/sh', '/bin/zsh']) { for (const path of ['~/code', '~/has space/a\'b;$(printf injected)`printf bad`', '~', '/absolute/a\'b;$(printf bad)', '~bad;rm/code']) { diff --git a/scripts/test-git-untracked.mjs b/scripts/test-git-untracked.mjs new file mode 100644 index 00000000..e4f8058b --- /dev/null +++ b/scripts/test-git-untracked.mjs @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** Extract a production Swift declaration; parameters are source/signature; returns the declaration body. */ +function declaration(source, signature) { + const start = source.indexOf(signature); + assert.notEqual(start, -1, signature); + const open = source.indexOf('{', start); + let depth = 1; + let end = open + 1; + for (; depth && end < source.length; end++) { + if (source[end] === '{') depth++; + if (source[end] === '}') depth--; + } + assert.equal(depth, 0); + return source.slice(start, end); +} + +test('untracked line counts preserve Git semantics and enforce bounded reads', async t => { + const scratch = mkdtempSync(join(tmpdir(), 'glint-count-regression-')); + try { + const source = readFileSync(join(root, 'Glint/Git/GitService.swift'), 'utf8'); + const limit = source.match(/maxLocalCountBytes\s*=\s*([\d_]+)/)?.[1]; + assert.ok(limit); + const count = declaration(source, 'private static func addCountFromBytes(') + .replace('private static', 'static').replaceAll('FileHandle', 'ObservedFileHandle') + .replaceAll('Darwin.open(', 'observedOpen(').replaceAll('Darwin.close(', 'observedClose('); + // The observer forwards the actual read to Foundation and can grow a real + // file between preflight and opening. The counting implementation is unchanged. + const harness = String.raw` +import Foundation +import Darwin +enum IOObservation { + static var requested: [Int] = []; static var opened = 0; static var growAtOpen = false + static var growBeforeRead = false; static var failRead = false; static var path = ""; static var race = "" + static var descriptors: [Int32] = []; static var closed: [Int32] = [] +} +/// Replace a preflighted path; parameter is its path; returns nothing or throws. +func performOpenRace(_ path: String) throws { + IOObservation.path = path + if IOObservation.growAtOpen { + try Data(repeating: 0x0A, count: Counts.maxLocalCountBytes + 200).write(to: URL(fileURLWithPath: path)) + } + if IOObservation.race == "fifo" { + try FileManager.default.removeItem(atPath: path) + guard mkfifo(path, 0o600) == 0 else { fatalError("mkfifo failed") } + } else if IOObservation.race == "symlink" { + let target = path + ".target" + try Data("target contents\n".utf8).write(to: URL(fileURLWithPath: target)) + try FileManager.default.removeItem(atPath: path) + try FileManager.default.createSymbolicLink(atPath: path, withDestinationPath: target) + } +} +/// Observe a real open after racing; parameters are path/flags; returns its actual descriptor. +func observedOpen(_ path: String, _ flags: Int32) -> Int32 { + IOObservation.opened += 1 + do { try performOpenRace(path) } catch { fatalError("fixture replacement failed") } + let fd = Darwin.open(path, flags) + if fd >= 0 { IOObservation.descriptors.append(fd) } + return fd +} +/// Record real descriptor closure; parameter is descriptor; returns Darwin's close result. +func observedClose(_ descriptor: Int32) -> Int32 { + IOObservation.closed.append(descriptor) + return Darwin.close(descriptor) +} +final class ObservedFileHandle { + let real: FileHandle + /// Open a real file after optional growth; parameter is its URL; returns a handle or throws. + init(forReadingFrom url: URL) throws { + IOObservation.opened += 1 + try performOpenRace(url.path) + real = try FileHandle(forReadingFrom: url) + } + /// Wrap the production descriptor; parameters are descriptor/ownership; returns a Foundation handle. + init(fileDescriptor: Int32, closeOnDealloc: Bool) { + real = FileHandle(fileDescriptor: fileDescriptor, closeOnDealloc: closeOnDealloc) + } + /// Forward a bounded read; parameter is the byte cap; returns real bytes or throws. + func read(upToCount count: Int) throws -> Data? { + IOObservation.requested.append(count) + if IOObservation.failRead { throw CocoaError(.fileReadUnknown) } + if IOObservation.growBeforeRead { + let writer = try FileHandle(forWritingTo: URL(fileURLWithPath: IOObservation.path)) + defer { try? writer.close() } + try writer.write(contentsOf: Data(repeating: 0x0A, count: Counts.maxLocalCountBytes + 200)) + } + return try real.read(upToCount: count) + } + /// Close the forwarded handle; no parameters; returns nothing or throws. + func close() throws { try real.close() } +} +enum Counts { + static let maxLocalCountBytes = LIMIT + COUNT +} +var failures = 0 +/// Assert one behavior; parameters are condition/name; returns nothing and records failure. +func verify(_ condition: Bool, _ name: String) { + if condition { print("PASS \(name)") } else { print("FAIL \(name)"); failures += 1 } +} +let dir = CommandLine.arguments[1] +if CommandLine.arguments.count > 2 { + let mode = CommandLine.arguments[2] + let path = dir + "/race-" + mode + try Data("original\n".utf8).write(to: URL(fileURLWithPath: path)) + if mode == "growth-before" { IOObservation.growAtOpen = true } + else if mode == "read-error" { IOObservation.failRead = true } + else { IOObservation.race = mode } + let result = Counts.addCountFromBytes(repo: dir, relPath: "race-" + mode) + verify(result == 0, "\(mode) replacement is rejected") + verify(mode == "read-error" ? IOObservation.requested == [Counts.maxLocalCountBytes + 1] : IOObservation.requested.isEmpty, + "\(mode) read attempt matches policy") + verify(IOObservation.descriptors == IOObservation.closed, "\(mode) descriptors are closed") + if failures > 0 { exit(1) } + exit(0) +} +/// Count fixture bytes through production code; parameter is file data; returns additions. +func count(_ bytes: Data) throws -> Int { + IOObservation.requested = []; IOObservation.opened = 0 + try bytes.write(to: URL(fileURLWithPath: dir + "/file")) + return Counts.addCountFromBytes(repo: dir, relPath: "file") +} +verify(try count(Data()) == 0, "empty") +verify(try count(Data("one\ntwo\n".utf8)) == 2, "LF text") +verify(try count(Data("one\ntwo".utf8)) == 2, "missing trailing LF") +verify(try count(Data([0xFF, 0x0A, 0xFE])) == 2, "non UTF-8 text") +verify(try count(Data([0, 0x0A])) == 0, "binary probe") +verify(try count(Data(repeating: 0x41, count: 8001) + Data([0, 0x0A])) == 1, "tail NUL stays text") +verify(try count(Data(repeating: 0x0A, count: Counts.maxLocalCountBytes)) == Counts.maxLocalCountBytes, "exact limit") +verify(IOObservation.requested == [Counts.maxLocalCountBytes + 1], "actual read uses limit plus one") +verify(try count(Data(repeating: 0x0A, count: Counts.maxLocalCountBytes + 1)) == 0, "oversized file") +verify(IOObservation.opened == 0, "oversized preflight prevents opening") +IOObservation.growBeforeRead = true +verify(try count(Data("small\n".utf8)) == 0, "file growth after preflight is rejected") +verify(IOObservation.requested == [Counts.maxLocalCountBytes + 1], "growing file read remains bounded") +IOObservation.growBeforeRead = false +IOObservation.opened = 0 +verify(Counts.addCountFromBytes(repo: dir, relPath: ".") == 0, "directory skipped") +verify(IOObservation.opened == 0, "non-regular file preflight prevents opening") +try FileManager.default.createSymbolicLink(atPath: dir + "/symlink", withDestinationPath: dir + "/file") +verify(Counts.addCountFromBytes(repo: dir, relPath: "symlink") == 0, "symlink skipped") +verify(IOObservation.opened == 0, "symlink does not follow target") +verify(Counts.addCountFromBytes(repo: dir, relPath: "missing") == 0, "missing file") +verify(IOObservation.descriptors == IOObservation.closed, "all normal descriptors are closed") +if failures > 0 { exit(1) } +`; + const swift = join(scratch, 'main.swift'); + const executable = join(scratch, 'count'); + writeFileSync(swift, harness.replace('LIMIT', limit).replace('COUNT', count)); + const compile = spawnSync('/usr/bin/swiftc', ['-module-cache-path', join(scratch, 'cache'), swift, '-o', executable], { encoding: 'utf8' }); + assert.equal(compile.status, 0, compile.stderr); + const result = spawnSync(executable, [scratch], { encoding: 'utf8' }); + console.log(result.stdout); + assert.equal(result.status, 0, result.stderr || result.stdout); + for (const race of ['fifo', 'symlink', 'growth-before', 'read-error']) { + await t.test(`path replacement before open: ${race}`, () => { + const raced = spawnSync(executable, [scratch, race], { encoding: 'utf8', timeout: 3000 }); + console.log(raced.stdout); + assert.equal(raced.error?.code, undefined, `blocked read: ${raced.error?.message}`); + assert.equal(raced.status, 0, raced.stderr || raced.stdout); + }); + } + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +}); diff --git a/scripts/test-http-lifecycle.mjs b/scripts/test-http-lifecycle.mjs new file mode 100644 index 00000000..c4d94c56 --- /dev/null +++ b/scripts/test-http-lifecycle.mjs @@ -0,0 +1,279 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const baseline = process.argv.includes("--baseline"); +const source = baseline + ? spawnSync("git", ["show", "HEAD:Glint/WebRemote/WebRemoteServer.swift"], { cwd: root, encoding: "utf8" }).stdout + : fs.readFileSync(path.join(root, "Glint/WebRemote/WebRemoteServer.swift"), "utf8"); + +/** Extract a shipped private method including its body; input is its name; returns its source. */ +function method(name) { + const start = source.indexOf(` private func ${name}(`); + assert.ok(start >= 0, `Missing method ${name}`); + const end = source.indexOf("\n private func ", start + 1); + return source.slice(start, end < 0 ? undefined : end); +} + +const handlerStart = source.indexOf(" private func handleHTTPConnection("); +const handlerEnd = source.indexOf(" private func serveHTTPRequest(", handlerStart); +assert.ok(handlerStart >= 0 && handlerEnd > handlerStart, "HTTP extraction markers changed"); +const admission = baseline ? "handleHTTPConnection(connection)" : "handleHTTPConnection(connection, runID: admissionRun)"; +const maximum = source.match(/static let maxHTTPConnections = (\d+)/)?.[1] ?? "32"; +// Use real Network.framework connections on an ephemeral loopback port. The +// asset lookup is a tiny fixture; admission, header reads, send completion and +// the entire stopLocked method come verbatim from the shipped server. +const fixture = String.raw`import Foundation +import Network +import Darwin +import os +private let webRemoteLogger = Logger(subsystem: "app.glint.tests", category: "HTTP") +private enum Status { case stopped } +private final class NoopClient { + /// Stand in for unrelated WebSocket cancellation; takes no inputs; returns nothing. + func cancel() {} +} +private struct NoopThrottle { + /// Stand in for unrelated auth cleanup; takes no inputs; returns nothing. + mutating func removeAll() {} +} +final class HTTPFixture { + static let maxHTTPConnections = MAXIMUM + private let queue = DispatchQueue(label: "glint.tests.http-lifecycle") + private var runID: UUID? = UUID() + private var admissionRun = UUID() + private var httpConnections: [ObjectIdentifier: NWConnection] = [:] + private var httpHeadersPending = Set() + private var httpSendGenerations: [ObjectIdentifier: UUID] = [:] + private var httpSendTimeouts: [ObjectIdentifier: DispatchWorkItem] = [:] + private let httpSendIdleTimeout: TimeInterval = 0.5 + private var accepted: [NWConnection] = [] + private let httpRequestTimeout: TimeInterval + private var httpListener: NWListener? + private var webSocketListener: NWListener? + private var clients: [UUID: NoopClient] = [:] + private var authThrottle = NoopThrottle() + private var readyListeners: Set = [] + private var token = "" + private var tokenKey = Data() + private var pathMonitor: NWPathMonitor? + private var pathRestartScheduled = false + private var interfaceRetryTimer: DispatchSourceTimer? + private var lastBoundAddress: String? + /// Configure a test lifetime; input is the HTTP deadline; returns a fixture. + init(timeout: TimeInterval) { httpRequestTimeout = timeout } + /// Start only an ephemeral loopback listener; input selects a stale generation; returns its port. + func start(stale: Bool = false) throws -> UInt16 { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(IPv4Address("127.0.0.1")!), port: .any) + let listener = try NWListener(using: parameters, on: .any) + httpListener = listener + admissionRun = stale ? UUID() : runID! + let ready = DispatchSemaphore(value: 0) + var listening = false + listener.stateUpdateHandler = { state in + switch state { + case .ready: listening = true; ready.signal() + case let .failed(error): print("Listener failed: \(error)"); ready.signal() + default: break + } + } + listener.newConnectionHandler = { [weak self] connection in + guard let self else { connection.cancel(); return } + // Retain baseline connections too, so ARC cannot imitate a timeout. + self.accepted.append(connection) + self.ADMISSION + } + listener.start(queue: queue) + guard ready.wait(timeout: .now() + 3) == .success, listening, + let port = listener.port, port.rawValue != 0 else { + throw POSIXError(.ETIMEDOUT) + } + return port.rawValue + } + /// Invoke the actual stop implementation; takes no inputs; returns nothing. + func stop() { queue.sync { stopLocked(emitStatus: false) } } + /// Clean fixture connections even against an unfixed baseline; takes no inputs; returns nothing. + func cleanup() { + queue.sync { + httpListener?.cancel() + for connection in accepted { connection.cancel() } + accepted.removeAll() + } + } + /// Stand in for unrelated terminal state; takes no inputs; returns no panes. + private func controlledPanesLocked() -> [String] { [] } + /// Stand in for unrelated subscriptions; takes no inputs; returns nothing. + private func updateSubscribedPanesLocked() {} + /// Stand in for unrelated terminal restoration; input is panes; returns nothing. + private func releaseTerminalSizes(_ panes: [String]) {} + /// Stand in for unrelated UI notifications; input is status; returns nothing. + private func emit(_ status: Status) {} + /// Complete an HTTP request without bundles or user files; inputs are data and connection; returns nothing. + private func serveHTTPRequest(_ data: Data, on connection: NWConnection) { + let response = Data("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK".utf8) + if String(decoding: data, as: UTF8.self).contains("/slow") { + queue.asyncAfter(deadline: .now() + 0.4) { self.sendHTTP(response, to: connection) } + } else { sendHTTP(response, to: connection) } + } + /// Complete a malformed request; inputs are status, reason and connection; returns nothing. + private func sendHTTPError(_ status: Int, reason: String, to connection: NWConnection) { + sendHTTP(Data("HTTP/1.1 \(status) \(reason)\r\nContent-Length: 0\r\n\r\n".utf8), to: connection) + } +HANDLERS +SEND +STOP +} +struct Failure: Error { let message: String } +/// Verify one condition; inputs are its value and label; throws on failure. +func expect(_ value: Bool, _ message: String) throws { if !value { throw Failure(message: message) } } +/// Connect a bounded raw HTTP peer; inputs are port and receive timeout; returns its file descriptor. +func connectPeer(_ port: UInt16, timeout: TimeInterval = 0.5) throws -> Int32 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.EMFILE) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { close(fd); throw POSIXError(.ECONNREFUSED) } + var interval = timeval(tv_sec: Int(timeout), tv_usec: Int32((timeout - floor(timeout)) * 1_000_000)) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &interval, socklen_t(MemoryLayout.size)) + var noSignal: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + return fd +} +/// Send bytes through a raw peer; inputs are descriptor and text; throws on failure. +func send(_ fd: Int32, _ text: String) throws { + let data = Data(text.utf8) + let count = data.withUnsafeBytes { Darwin.send(fd, $0.baseAddress, $0.count, 0) } + guard count == data.count else { throw POSIXError(.EPIPE) } +} +/// Observe closure within the peer's deadline; input is its descriptor; returns whether EOF/reset arrived. +func peerClosed(_ fd: Int32) -> Bool { + var byte: UInt8 = 0 + let count = recv(fd, &byte, 1, 0) + return count == 0 || (count < 0 && (errno == ECONNRESET || errno == ENOTCONN)) +} +/// Verify silent connections expire; takes no inputs; throws on failure. +func testSilent() throws { + let fixture = HTTPFixture(timeout: 0.2) + defer { fixture.cleanup() } + let peer = try connectPeer(fixture.start()) + defer { close(peer) } + try expect(peerClosed(peer), "silent connection did not expire") +} +/// Verify partial progress does not reset the fixed deadline; takes no inputs; throws on failure. +func testPartial() throws { + let fixture = HTTPFixture(timeout: 0.3) + defer { fixture.cleanup() } + let peer = try connectPeer(fixture.start()) + defer { close(peer) } + let started = Date() + try send(peer, "GET / HTTP/1.1\r\nX: ") + usleep(100_000) + try send(peer, "a") + usleep(100_000) + try send(peer, "b") + try expect(peerClosed(peer), "partial headers did not expire") + try expect(Date().timeIntervalSince(started) < 0.45, "header progress extended the fixed deadline") +} +/// Verify the 33rd pending connection is rejected before the deadline; takes no inputs; throws on failure. +func testCapacity() throws { + let fixture = HTTPFixture(timeout: 3) + defer { fixture.cleanup() } + let port = try fixture.start() + var peers: [Int32] = [] + defer { for peer in peers { close(peer) } } + for _ in 0.. 0 && String(decoding: bytes.prefix(max(count, 0)), as: UTF8.self).contains("200 OK"), "valid HTTP request failed") + try expect(peerClosed(peer), "successful response left its connection alive") +} +/// A valid request may outlive its header deadline; no inputs; throws on premature cancellation. +func testResponseOutlivesHeaderDeadline() throws { + let fixture = HTTPFixture(timeout: 0.15) + defer { fixture.cleanup() } + let peer = try connectPeer(fixture.start(), timeout: 1) + defer { close(peer) } + try send(peer, "GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n") + var bytes = [UInt8](repeating: 0, count: 4096) + let count = recv(peer, &bytes, bytes.count, 0) + try expect(count > 0 && String(decoding: bytes.prefix(max(count, 0)), as: UTF8.self).contains("200 OK"), "header deadline cancelled a valid response phase") +} +let tests: [(String, () throws -> Void)] = [ + ("silent headers expire", testSilent), ("partial headers have a fixed deadline", testPartial), + ("HTTP connection cap is enforced", testCapacity), ("stop cancels pending HTTP", testStop), + ("stale listener generations reject peers", testStaleGeneration), ("valid HTTP response completes", testCompleteRequest), ("response outlives header deadline", testResponseOutlivesHeaderDeadline), +] +var failures = 0 +for (name, test) in tests { + do { try test(); print("PASS: \(name)") } + catch { failures += 1; print("FAIL: \(name): \(error)") } +} +print("Executed \(tests.count) HTTP lifecycle cases; \(failures) failures") +exit(failures == 0 ? 0 : 1) +`; +const directory = fs.mkdtempSync(path.join(os.tmpdir(), "glint-http-lifecycle-")); +try { + const swift = fixture.replace("MAXIMUM", maximum).replace("ADMISSION", admission) + .replace("HANDLERS", source.slice(handlerStart, handlerEnd)) + .replace("SEND", method("sendHTTP") + "\n" + method("sendHTTPChunk")).replace("STOP", method("stopLocked")); + fs.writeFileSync(path.join(directory, "main.swift"), swift); + const binary = path.join(directory, "http-lifecycle-tests"); + const compile = spawnSync("swiftc", ["-swift-version", "5", "-module-cache-path", path.join(os.tmpdir(), "glint-http-lifecycle-cache"), path.join(directory, "main.swift"), "-o", binary], { encoding: "utf8", timeout: 60000 }); + process.stdout.write(compile.stdout ?? ""); + process.stderr.write(compile.stderr ?? ""); + if (compile.error) throw compile.error; + if (compile.status !== 0) process.exitCode = compile.status ?? 1; + else { + const run = spawnSync(binary, [], { encoding: "utf8", timeout: 15000 }); + process.stdout.write(run.stdout ?? ""); + process.stderr.write(run.stderr ?? ""); + if (run.error) throw run.error; + process.exitCode = run.status ?? 1; + } +} finally { + fs.rmSync(directory, { recursive: true, force: true }); +} diff --git a/scripts/test-usage-refresh.mjs b/scripts/test-usage-refresh.mjs new file mode 100644 index 00000000..3cc7eb3a --- /dev/null +++ b/scripts/test-usage-refresh.mjs @@ -0,0 +1,436 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const source = fs.readFileSync(path.join(root, "Glint/Agent/UsageStore.swift"), "utf8"); +const start = source.indexOf("struct AgentQuota:"); +const end = source.indexOf("// MARK: - Codex (disk)"); +assert.ok(start >= 0 && end > start, "UsageStore extraction markers changed"); +const homesSource = fs.readFileSync(path.join(root, "Glint/Agent/CodexHome.swift"), "utf8"); +const homeEnd = homesSource.indexOf("@MainActor\nfinal class CodexHomeStore:"); +assert.ok(homeEnd > 0, "CodexHome extraction markers changed"); + +/** Keep a real reader declaration while replacing its body; input identifies reader and probe; returns a safe Swift enum. */ +function probeReader({ type, method, body, documentation }) { + const enumStart = source.indexOf(`enum ${type} {`); + assert.ok(enumStart >= 0, `Missing reader ${type}`); + const declarationStart = source.indexOf(` static func ${method}(`, enumStart); + const bodyStart = source.indexOf("{", declarationStart); + assert.ok(declarationStart > enumStart && bodyStart > declarationStart, `Missing declaration ${type}.${method}`); + const declaration = source.slice(declarationStart, bodyStart).trimEnd(); + assert.match(declaration, /\)\s+async\s+->\s+AgentQuota\?$/, + `Unexpected return contract for ${type}.${method}`); + return `enum ${type} {\n /// ${documentation}\n${declaration} {\n ${body}\n }\n}\n`; +} + +// Compile the shipped store and data models, replacing only platform wrappers, +// settings persistence and external readers. Every request suspends on a test +// continuation; this runner never opens auth files, keychains, hooks or sockets. +// Reader argument labels, types and defaults are copied from production so an +// invalid function reference cannot compile merely because a stub lost inputs. +const stubs = `import Foundation +protocol ObservableObject {} +@propertyWrapper struct Published { + var wrappedValue: Value + /// Wrap an observable test value; input is the initial value; returns its wrapper. + init(wrappedValue: Value) { self.wrappedValue = wrappedValue } +} +final class UserDefaults { + static let standard = UserDefaults() + private var values: [String: Any] = [:] + /// Read an in-memory preference; input is its key; returns its stored value. + func object(forKey key: String) -> Any? { values[key] } + /// Read in-memory data; input is its key; returns data when present. + func data(forKey key: String) -> Data? { values[key] as? Data } + /// Set an in-memory preference; inputs are value and key; returns nothing. + func set(_ value: Any, forKey key: String) { values[key] = value } + /// Remove an in-memory preference; input is its key; returns nothing. + func removeObject(forKey key: String) { values[key] = nil } + /// Reset test preferences; takes no inputs and returns nothing. + func reset() { values.removeAll() } +} +@MainActor enum CodexHomeStore { + static var homes: [CodexHome] = [] + /// Return configured test homes; takes no inputs; returns the test snapshot. + static func configuredHomes() -> [CodexHome] { homes } +} +enum CodexHookInstaller { + /// Avoid hook filesystem access; input is a home URL; returns a placeholder. + static func status(in home: URL) -> CodexHookStatus { .installed } +} +enum CodexLiveReader { + /// Avoid credential access; input is a home URL; returns a placeholder. + static func authStatus(from home: URL) -> CodexAuthStatus { .found } +} +${probeReader({ type: "CodexUsageReader", method: "readPreferLive", body: "await Probe.shared.read(codexHome.path)", + documentation: "Suspend a test quota request; input is its home; returns the injected quota." })} +${probeReader({ type: "ClaudeUsageReader", method: "read", body: 'await Probe.shared.read("claude")', + documentation: "Suspend a test quota request; takes no inputs; returns the injected quota." })} +${probeReader({ type: "GrokUsageReader", method: "read", body: 'await Probe.shared.read("grok")', + documentation: "Suspend a test quota request; input is its optional home; returns the injected quota." })} +actor Probe { + static let shared = Probe() + private var counts: [String: Int] = [:] + private var continuations: [String: [Int: CheckedContinuation]] = [:] + private var cancelled: Set = [] + /// Record and suspend a request, deliberately tolerating cancellation; input is its source; returns an injected quota. + func read(_ key: String) async -> AgentQuota? { + let index = (counts[key] ?? 0) + 1 + counts[key] = index + let result: AgentQuota? = await withCheckedContinuation { continuation in + continuations[key, default: [:]][index] = continuation + } + if Task.isCancelled { cancelled.insert("\\(key)#\\(index)") } + return result + } + /// Count requests; input is their source; returns the number started. + func count(_ key: String) -> Int { counts[key] ?? 0 } + /// Complete one request; inputs are source, index and quota; returns nothing. + func complete(_ key: String, index: Int = 1, percent: Double?) { + let quota = percent.map { AgentQuota(sessionPercent: $0, weeklyPercent: nil, sessionResetsAt: nil, weeklyResetsAt: nil, planType: nil) } + continuations[key]?[index]?.resume(returning: quota) + continuations[key]?[index] = nil + } + /// Inspect cancellation; inputs are source and index; returns whether its reader was cancelled. + func wasCancelled(_ key: String, index: Int = 1) -> Bool { cancelled.contains("\\(key)#\\(index)") } + /// Complete all pending reads; takes no inputs and returns nothing. + func finishAll() { + let pending = continuations.values.flatMap { $0.values } + continuations.removeAll() + for continuation in pending { continuation.resume(returning: nil) } + } + /// Reset counters after completion; takes no inputs and returns nothing. + func reset() { counts.removeAll(); cancelled.removeAll() } +} +struct Failure: Error { let message: String } +/// Check a behavior; inputs are its condition and failure explanation; throws on failure. +func expect(_ condition: Bool, _ message: String) throws { + if !condition { throw Failure(message: message) } +} +`; +const tests = ` +@MainActor enum RefreshTests { + static var stores: [UsageStore] = [] + static var refreshTimeout: TimeInterval = 30 + /// Drain runnable main-actor work without wall-clock sleeps; takes no inputs; returns nothing. + static func settle() async { for _ in 0..<100 { await Task.yield() } } + /// Create a store with isolated enabled flags; inputs are enabled agents; returns the store. + static func store(claude: Bool = false, codex: Bool = false, grok: Bool = false) -> UsageStore { + UserDefaults.standard.set(claude, forKey: "glint.showClaudeUsage") + UserDefaults.standard.set(codex, forKey: "glint.showCodexUsage") + UserDefaults.standard.set(grok, forKey: "glint.showGrokUsage") + let result = UsageStore(refreshTimeout: refreshTimeout) + stores.append(result) + return result + } + /// Disable stores and drain readers after each case; takes no inputs; returns nothing. + static func cleanup() async { + for store in stores { store.claudeEnabled = false; store.codexEnabled = false; store.grokEnabled = false } + await Probe.shared.finishAll() + await settle() + stores.removeAll() + await Probe.shared.reset() + UserDefaults.standard.reset() + CodexHomeStore.homes = [] + refreshTimeout = 30 + } + /// A first enable must issue one read; takes no inputs; throws on mismatch. + static func firstEnable() async throws { + let usage = store() + usage.claudeEnabled = true + await settle() + try expect(await Probe.shared.count("claude") == 1, "first enable sent duplicate quota requests") + let grok = store() + grok.grokEnabled = true + let home = CodexHome(path: "/tmp/glint-first-enable") + CodexHomeStore.homes = [home] + let codex = store() + codex.codexEnabled = true + await settle() + try expect(await Probe.shared.count("grok") == 1, "first Grok enable sent duplicate requests") + try expect(await Probe.shared.count(home.resolvedURL.path) == 1, "first Codex enable sent duplicate requests") + } + /// Manual refreshes must share per-agent pending work; takes no inputs; throws on mismatch. + static func agentCoalescing() async throws { + let usage = store(claude: true, grok: true) + await settle() + usage.refreshNow(); usage.refreshNow() + await settle() + try expect(await Probe.shared.count("claude") == 1, "Claude refreshes were not coalesced") + try expect(await Probe.shared.count("grok") == 1, "Grok refreshes were not coalesced") + } + /// A disabled and reenabled agent rejects its old result; takes no inputs; throws on mismatch. + static func agentCancellation() async throws { + let usage = store(claude: true) + await settle() + usage.claudeEnabled = false + usage.claudeEnabled = true + await settle() + await Probe.shared.complete("claude", index: 2, percent: 70) + await settle() + await Probe.shared.complete("claude", percent: 10) + await settle() + try expect(usage.claude?.sessionPercent == 70, "late Claude quota overwrote the new enable cycle") + try expect(await Probe.shared.wasCancelled("claude"), "disabled Claude request was not cancelled") + } + /// Grok rejects the previous enable cycle's response; takes no inputs; throws on mismatch. + static func grokCancellation() async throws { + let usage = store(grok: true) + await settle() + usage.grokEnabled = false; usage.grokEnabled = true + await settle() + await Probe.shared.complete("grok", index: 2, percent: 65) + await settle() + await Probe.shared.complete("grok", percent: 5) + await settle() + try expect(usage.grok?.sessionPercent == 65, "late Grok quota overwrote a fresh result") + try expect(await Probe.shared.wasCancelled("grok"), "disabled Grok request was not cancelled") + } + /// Multiple homes each share one pending read; takes no inputs; throws on mismatch. + static func homeCoalescing() async throws { + CodexHomeStore.homes = [CodexHome(path: "/tmp/glint-home-A"), CodexHome(path: "/tmp/glint-home-B")] + let usage = store(codex: true) + await settle() + usage.refreshNow(); usage.refreshNow() + await settle() + try expect(await Probe.shared.count("/tmp/glint-home-A") == 1, "home A has overlapping reads") + try expect(await Probe.shared.count("/tmp/glint-home-B") == 1, "home B has overlapping reads") + } + /// Editing enabled homes preserves unaffected reads and cancels removed work; takes no inputs; throws on mismatch. + static func homeChanges() async throws { + let a = CodexHome(path: "/tmp/glint-home-A"), b = CodexHome(path: "/tmp/glint-home-B") + let c = CodexHome(path: "/tmp/glint-home-C") + CodexHomeStore.homes = [a, b] + let usage = store(codex: true) + await settle() + CodexHomeStore.homes = [a, c] + usage.refreshNow() + await settle() + try expect(await Probe.shared.count(a.resolvedURL.path) == 1, "changing home B restarted unchanged home A") + await Probe.shared.complete(c.resolvedURL.path, percent: 40) + await Probe.shared.complete(a.resolvedURL.path, percent: 20) + await Probe.shared.complete(b.resolvedURL.path, percent: 99) + await settle() + try expect(Set(usage.codexHomeStatuses.map(\\.id)) == Set([a.id, c.id]), "removed home appeared in the final quota rows") + try expect(await Probe.shared.wasCancelled(b.resolvedURL.path), "removed home request was not cancelled") + } + /// A changed path with the same home ID cannot publish the old path's result; takes no inputs; throws on mismatch. + static func homePathChange() async throws { + let a = CodexHome(path: "/tmp/glint-home-old") + CodexHomeStore.homes = [a] + let usage = store(codex: true) + await settle() + var updated = a; updated.path = "/tmp/glint-home-new" + CodexHomeStore.homes = [updated] + usage.refreshNow() + await settle() + await Probe.shared.complete(updated.resolvedURL.path, percent: 35) + await settle() + await Probe.shared.complete(a.resolvedURL.path, percent: 95) + await settle() + try expect(usage.codex?.sessionPercent == 35, "old home path overwrote current quota") + try expect(usage.codexHomeStatuses.first?.resolvedURL == updated.resolvedURL, "old home path remained visible") + try expect(await Probe.shared.wasCancelled(a.resolvedURL.path), "changed home path request was not cancelled") + } + /// Disabling usage cancels Codex readers and prevents late publication; takes no inputs; throws on mismatch. + static func homeDisable() async throws { + let home = CodexHome(path: "/tmp/glint-home-disabled") + CodexHomeStore.homes = [home] + let usage = store(codex: true) + await settle() + usage.codexEnabled = false + await Probe.shared.complete(home.resolvedURL.path, percent: 88) + await settle() + try expect(usage.codex == nil && usage.codexHomeStatuses.isEmpty, "disabled Codex published a late quota") + try expect(await Probe.shared.wasCancelled(home.resolvedURL.path), "disabled Codex read was not cancelled") + } + /// Completed and failed work frees its slot for the next poll; takes no inputs; throws on mismatch. + static func completedRefreshCanRepeat() async throws { + let usage = store(claude: true, grok: true) + await settle() + await Probe.shared.complete("claude", percent: 25) + await Probe.shared.complete("grok", percent: nil) + await settle() + usage.refreshNow() + await settle() + try expect(await Probe.shared.count("claude") == 2, "completed Claude task was never released") + try expect(await Probe.shared.count("grok") == 2, "failed Grok task was never released") + await Probe.shared.complete("claude", index: 2, percent: nil) + await settle() + try expect(usage.claude?.sessionPercent == 25, "transient failure erased the last known quota") + } + /// Renaming a pending home reuses its request and latest metadata; takes no inputs; throws on mismatch. + static func homeRename() async throws { + var home = CodexHome(label: "Before", path: "/tmp/glint-home-rename") + CodexHomeStore.homes = [home] + let usage = store(codex: true) + await settle() + home.label = "After" + CodexHomeStore.homes = [home] + usage.refreshNow() + await settle() + await Probe.shared.complete(home.resolvedURL.path, percent: 40) + await settle() + try expect(await Probe.shared.count(home.resolvedURL.path) == 1, "renaming restarted a pending home read") + try expect(usage.codexHomeStatuses.first?.home.label == "After", "completed home read reverted its label") + usage.refreshNow() + await settle() + try expect(await Probe.shared.count(home.resolvedURL.path) == 2, "completed home task was not released") + } + /// A hung agent read must release its refresh slot and reject late results; no inputs; throws on mismatch. + static func agentDeadline() async throws { + refreshTimeout = 0.2 + let usage = store(claude: true, grok: true) + await settle() + try await Task.sleep(nanoseconds: 350_000_000) + usage.refreshNow() + await settle() + try expect(await Probe.shared.count("claude") == 2, "hung Claude read permanently blocked refresh") + try expect(await Probe.shared.count("grok") == 2, "hung Grok read permanently blocked refresh") + await Probe.shared.complete("claude", index: 2, percent: 60) + await Probe.shared.complete("grok", index: 2, percent: 70) + await settle() + await Probe.shared.complete("claude", percent: 1) + await Probe.shared.complete("grok", percent: 2) + await settle() + try expect(usage.claude?.sessionPercent == 60 && usage.grok?.sessionPercent == 70, "timed-out agent result overwrote a newer quota") + let claudeCancelled = await Probe.shared.wasCancelled("claude") + let grokCancelled = await Probe.shared.wasCancelled("grok") + try expect(claudeCancelled && grokCancelled, "expired agent reads were not cancelled") + } + /// Hung homes must release slots without erasing previously available quota; no inputs; throws on mismatch. + static func homeDeadline() async throws { + refreshTimeout = 0.2 + let home = CodexHome(path: "/tmp/glint-home-timeout") + CodexHomeStore.homes = [home] + let usage = store(codex: true) + await settle() + await Probe.shared.complete(home.resolvedURL.path, percent: 35) + await settle() + usage.refreshNow() + await settle() + try await Task.sleep(nanoseconds: 350_000_000) + try expect(usage.codex?.sessionPercent == 35, "home timeout erased the last known quota") + usage.refreshNow() + await settle() + try expect(await Probe.shared.count(home.resolvedURL.path) == 3, "hung home read permanently blocked refresh") + await Probe.shared.complete(home.resolvedURL.path, index: 3, percent: 45) + await settle() + await Probe.shared.complete(home.resolvedURL.path, index: 2, percent: 99) + await settle() + try expect(usage.codex?.sessionPercent == 45, "expired home result replaced a later successful refresh") + try expect(await Probe.shared.wasCancelled(home.resolvedURL.path, index: 2), "expired home request was not cancelled") + } + /// Repeated hung reads may retry and a completed request's timer cannot clear a later request; no inputs; throws on mismatch. + static func repeatedDeadline() async throws { + refreshTimeout = 0.2 + let usage = store(claude: true) + await settle() + try await Task.sleep(nanoseconds: 350_000_000) + usage.refreshNow() + await settle() + try await Task.sleep(nanoseconds: 350_000_000) + usage.refreshNow() + await settle() + try expect(await Probe.shared.count("claude") == 3, "second timeout left the refresh slot occupied") + await Probe.shared.complete("claude", index: 3, percent: 55) + await settle() + usage.refreshNow() + await settle() + await Probe.shared.complete("claude", index: 4, percent: 65) + await settle() + await Probe.shared.complete("claude", index: 1, percent: 95) + await Probe.shared.complete("claude", index: 2, percent: 85) + await settle() + try expect(usage.claude?.sessionPercent == 65, "orphaned deadline/read changed a newer completed quota") + } + /// A first home timeout must end its initial loading indicator; no inputs; throws on mismatch. + static func firstHomeDeadline() async throws { + refreshTimeout = 0.2 + let home = CodexHome(path: "/tmp/glint-first-home-timeout") + CodexHomeStore.homes = [home] + let usage = store(codex: true) + await settle() + try await Task.sleep(nanoseconds: 350_000_000) + if case .loading = usage.codexHomeStatuses.first?.quotaStatus { + throw Failure(message: "home timeout left its loading indicator running forever") + } + usage.refreshNow() + await settle() + try expect(await Probe.shared.count(home.resolvedURL.path) == 2, "initial home timeout did not permit retry") + } + /// Dropping a store cancels its readers without retaining the store; takes no inputs; throws on mismatch. + static func deallocation() async throws { + let home = CodexHome(path: "/tmp/glint-home-deinit") + CodexHomeStore.homes = [home] + var usage: UsageStore? = store(claude: true, codex: true, grok: true) + weak var released = usage + await settle() + stores.removeAll() + usage = nil + await settle() + try expect(released == nil, "pending reads retained the UsageStore") + await Probe.shared.finishAll() + await settle() + try expect(await Probe.shared.wasCancelled("claude"), "deinit did not cancel Claude") + try expect(await Probe.shared.wasCancelled("grok"), "deinit did not cancel Grok") + try expect(await Probe.shared.wasCancelled(home.resolvedURL.path), "deinit did not cancel Codex") + released = nil + } +} +@main struct Runner { + /// Run isolated behavior regressions; takes no inputs; exits nonzero on failure. + @MainActor static func main() async { + let tests: [(String, () async throws -> Void)] = [ + ("first enable performs one request", RefreshTests.firstEnable), + ("pending agent requests coalesce", RefreshTests.agentCoalescing), + ("Claude enable cycles cancel and reject stale results", RefreshTests.agentCancellation), + ("Grok enable cycles cancel and reject stale results", RefreshTests.grokCancellation), + ("pending requests coalesce per Codex home", RefreshTests.homeCoalescing), + ("home changes preserve unaffected work", RefreshTests.homeChanges), + ("changed home paths cannot publish old results", RefreshTests.homePathChange), + ("disabling Codex cancels pending work", RefreshTests.homeDisable), + ("completed work permits subsequent polls", RefreshTests.completedRefreshCanRepeat), + ("renaming a home reuses its pending request", RefreshTests.homeRename), + ("hung agent reads expire without waiting for cancellation", RefreshTests.agentDeadline), + ("home timeouts retain quota and reject late results", RefreshTests.homeDeadline), + ("repeated timeouts release refresh slots", RefreshTests.repeatedDeadline), + ("initial home timeout ends loading", RefreshTests.firstHomeDeadline), + ("deinitializing the store cancels its readers", RefreshTests.deallocation), + ] + var failures = 0 + for (name, test) in tests { + do { try await test(); print("PASS: \\(name)") } + catch { failures += 1; print("FAIL: \\(name): \\(error)") } + await RefreshTests.cleanup() + } + print("Executed \\(tests.count) usage-refresh cases; \\(failures) failures") + exit(failures == 0 ? 0 : 1) + } +} +`; +const directory = fs.mkdtempSync(path.join(os.tmpdir(), "glint-usage-refresh-")); +try { + const swift = path.join(directory, "UsageRefresh.swift"); + fs.writeFileSync(swift, stubs + homesSource.slice(homesSource.indexOf("struct CodexHome:"), homeEnd) + + source.slice(start, end) + tests); + const binary = path.join(directory, "usage-refresh-tests"); + const compile = spawnSync("swiftc", ["-swift-version", "5", "-parse-as-library", "-module-cache-path", path.join(os.tmpdir(), "glint-usage-refresh-module-cache"), "-o", binary, swift], { encoding: "utf8", timeout: 60000 }); + process.stdout.write(compile.stdout ?? ""); + process.stderr.write(compile.stderr ?? ""); + if (compile.error) throw compile.error; + if (compile.status !== 0) process.exitCode = compile.status ?? 1; + else { + const run = spawnSync(binary, [], { encoding: "utf8", timeout: 15000 }); + process.stdout.write(run.stdout ?? ""); + process.stderr.write(run.stderr ?? ""); + if (run.error) throw run.error; + process.exitCode = run.status ?? 1; + } +} finally { + fs.rmSync(directory, { recursive: true, force: true }); +} diff --git a/scripts/test-web-file-admission.mjs b/scripts/test-web-file-admission.mjs new file mode 100644 index 00000000..83fa1d3d --- /dev/null +++ b/scripts/test-web-file-admission.mjs @@ -0,0 +1,322 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const baseline = process.argv.includes("--baseline"); +const source = baseline + ? spawnSync("git", ["show", "HEAD:Glint/WebRemote/WebRemoteServer.swift"], { cwd: root, encoding: "utf8" }).stdout + : fs.readFileSync(path.join(root, "Glint/WebRemote/WebRemoteServer.swift"), "utf8"); +const protocolSource = fs.readFileSync(path.join(root, "Glint/WebRemote/WebRemoteProtocol.swift"), "utf8"); +const requestErrorStart = protocolSource.indexOf(" func error(_ code: WebRemoteFiles.ErrorCode)"); +const requestErrorEnd = protocolSource.indexOf("\n }", requestErrorStart) + 6; +assert.ok(requestErrorStart >= 0 && requestErrorEnd > requestErrorStart); +const requestError = protocolSource.slice(requestErrorStart, requestErrorEnd); +const codeStart = protocolSource.indexOf(" enum ErrorCode: String, Error {"); +const codeEnd = protocolSource.indexOf("\n }", codeStart) + 6; +assert.ok(codeStart >= 0 && codeEnd > codeStart); +const errorCodes = protocolSource.slice(codeStart, codeEnd); +const start = source.indexOf(" private func accessFiles("); +const end = source.indexOf("\n private func ", start + 1); +assert.ok(start >= 0 && end > start, "accessFiles extraction markers changed"); + +// Exercise the complete shipped accessFiles implementation, including all of +// its main/server/background queue hops. Replace file I/O with an NSCondition +// gate; workspace/file-request types and transport are narrow in-memory stubs. +// Disconnect/stop here only clear fixture clients: these tests verify that the +// work budget survives that change. test-http-lifecycle.mjs separately executes +// the production stopLocked method over real loopback connections. +const fixture = String.raw`import Foundation +struct Failure: Error { let message: String } +/// Verify a regression condition; inputs are condition and explanation; throws on failure. +func expect(_ value: Bool, _ message: String) throws { if !value { throw Failure(message: message) } } +final class IOGate: @unchecked Sendable { + private let condition = NSCondition() + private var releases: Set = [] + private var autoRelease = false + private var started: [String] = [] + private var running = 0 + private var maximum = 0 + /// Block one synthetic operation; input is its unique path; returns after explicit release. + func perform(_ path: String) { + condition.lock() + started.append(path) + running += 1 + maximum = max(maximum, running) + while !autoRelease && !releases.contains(path) { condition.wait() } + running -= 1 + condition.unlock() + } + /// Release one operation; input is its unique path; returns nothing. + func release(_ path: String) { condition.lock(); releases.insert(path); condition.broadcast(); condition.unlock() } + /// Release current and future operations for cleanup; takes no inputs; returns nothing. + func releaseAll() { condition.lock(); autoRelease = true; condition.broadcast(); condition.unlock() } + /// Snapshot started paths and concurrency; takes no inputs; returns observed counters. + func snapshot() -> (paths: [String], running: Int, maximum: Int) { + condition.lock(); defer { condition.unlock() } + return (started, running, maximum) + } +} +enum WebRemoteFiles { +FILECODES + static let maxEntries = 200 + static var gate = IOGate() + /// Classify a synthetic image; input is path; returns whether its extension is PNG. + static func isImage(_ path: String) -> Bool { path.hasSuffix(".png") } + /// Classify a synthetic HTML page; input is path; returns whether its extension is HTML. + static func isHTML(_ path: String) -> Bool { path.hasSuffix(".html") } + /// Simulate a blocked image read; inputs are root and path; returns a small injected payload or error. + static func readImage(root: String, path: String) -> Result { + gate.perform(path) + return path.contains("fail") ? .failure(.unavailable) : .success(Data([1, 2])) + } + /// Simulate a blocked text read; inputs are root and path; returns injected text or error. + static func read(root: String, path: String) -> Result { + gate.perform(path) + return path.contains("fail") ? .failure(.unavailable) : .success("contents") + } + /// Simulate a blocked listing; inputs are root and path; returns an injected listing or error. + static func list(root: String, path: String) -> Result<[[String: String]], ErrorCode> { + gate.perform(path) + return path.contains("fail") ? .failure(.unavailable) : .success([["name": "file.txt"]]) + } +} +final class WorkspaceStore { + static var current: WorkspaceStore? + var root: String? = "/allowed" + /// Return an in-memory file root; inputs are workspace and pane; returns the configured root. + func webRemoteFileRoot(workspace: UUID, pane: String) -> String? { root } +} +struct WebRemoteFileRequest { + var workspace = UUID() + var pane = "pane" + var path: String + var root = "/allowed" + var requestID = UUID().uuidString + var read = true + /// Validate the fixture root binding; input is the current root; returns its path or an error. + func validatedRoot(current: String?) -> Result { + guard let current else { return .failure(.rootUnavailable) } + guard root == current else { return .failure(.rootChanged) } + return .success(current) + } + /// Encode a fixture error; input is its code; returns a correlated response dictionary. +REQUESTERROR +} +final class AdmissionFixture: @unchecked Sendable { + private let queue = DispatchQueue(label: "glint.tests.file-admission") + private var clients: [UUID: Bool] = [:] + private var activeFileRequests: [UUID: UUID] = [:] + private var drops: Set = [] + private var replies: [(UUID, [String: Any])] = [] + private var suppressed = 0 + /// Register a synthetic client; takes no inputs; returns its new identity. + func addClient() -> UUID { queue.sync { let id = UUID(); clients[id] = true; return id } } + /// Remove a client without completing its I/O; input is its identity; returns nothing. + func disconnect(_ id: UUID) { queue.sync { clients[id] = nil } } + /// Model stop/restart's client clearing only; takes no inputs; returns nothing. + func clearClients() { queue.sync { clients.removeAll() } } + /// Run real admission on its serial queue; inputs are request and client; returns nothing. + func submit(_ request: WebRemoteFileRequest, client: UUID) { queue.sync { accessFiles(request, clientID: client) } } + /// Snapshot ticket and transport state; takes no inputs; returns observed state. + func snapshot() -> (tickets: Int, drops: Set, replies: [(UUID, [String: Any])], suppressed: Int) { + queue.sync { (activeFileRequests.count, drops, replies, suppressed) } + } + /// Record the production admission failure action; inputs are client and reason; returns nothing. + private func dropClientLocked(_ id: UUID, reason: String) { drops.insert(id); clients[id] = nil } + /// Model production's missing-client no-op; inputs are response and client; returns nothing. + private func sendJSON(_ response: [String: Any], to id: UUID) { + guard clients[id] != nil else { suppressed += 1; return } + replies.append((id, response)) + } +ACCESS +} +@MainActor enum AdmissionTests { + static var fixture: AdmissionFixture! + /// Let queued production callbacks progress; takes no inputs; returns nothing. + static func settle() async { for _ in 0..<100 { await Task.yield() } } + /// Wait for an observed event with a bounded deadline; inputs are explanation and predicate; throws on timeout. + static func until(_ label: String, _ predicate: () -> Bool) async throws { + for _ in 0..<2000 { + if predicate() { return } + try await Task.sleep(nanoseconds: 1_000_000) + } + throw Failure(message: "timeout: \(label)") + } + /// Prepare isolated gate/store/transport state; takes no inputs; returns nothing. + static func setup() { + WebRemoteFiles.gate = IOGate() + WorkspaceStore.current = WorkspaceStore() + fixture = AdmissionFixture() + } + /// Release synthetic I/O and drain callbacks; takes no inputs; returns nothing. + static func cleanup() async { + WebRemoteFiles.gate.releaseAll() + await settle() + try? await until("cleanup I/O") { WebRemoteFiles.gate.snapshot().running == 0 } + await settle() + fixture = nil + WorkspaceStore.current = nil + } + /// A client has two slots and keeps their tickets after overflow; takes no inputs; throws on mismatch. + static func clientLimit() async throws { + let client = fixture.addClient() + fixture.submit(WebRemoteFileRequest(path: "one.txt"), client: client) + fixture.submit(WebRemoteFileRequest(path: "two.txt"), client: client) + try await until("two blocked reads") { WebRemoteFiles.gate.snapshot().running == 2 } + fixture.submit(WebRemoteFileRequest(path: "three.txt"), client: client) + try await until("per-client overflow response") { fixture.snapshot().replies.contains { $0.0 == client } } + try expect(!fixture.snapshot().drops.contains(client), "a file burst disconnected its terminal") + try expect(fixture.snapshot().replies.last?.1["code"] as? String == "file-busy", "overflow did not return busy") + try expect(fixture.snapshot().replies.last?.1["path"] as? String == "three.txt", "busy response lost request correlation") + try expect(fixture.snapshot().tickets == 2, "dropping a client freed running I/O tickets") + try expect(WebRemoteFiles.gate.snapshot().paths.count == 2, "third request started I/O") + WebRemoteFiles.gate.releaseAll() + try await until("dropped client's tickets released") { fixture.snapshot().tickets == 0 } + try expect(fixture.snapshot().replies.count == 3, "connected client did not receive busy and both completed reads") + } + /// Global tickets survive disconnects and restarts until I/O actually completes; takes no inputs; throws on mismatch. + static func globalLimitSurvivesRestart() async throws { + var clients: [UUID] = [] + for index in 0..<4 { + let client = fixture.addClient(); clients.append(client) + fixture.submit(WebRemoteFileRequest(path: "\(index)-a.txt"), client: client) + fixture.submit(WebRemoteFileRequest(path: "\(index)-b.txt"), client: client) + } + try await until("eight blocked global reads") { WebRemoteFiles.gate.snapshot().running == 8 } + fixture.disconnect(clients[0]) + fixture.clearClients() + try expect(fixture.snapshot().tickets == 8, "disconnect/stop freed unfinished global work") + let rejected = fixture.addClient() + fixture.submit(WebRemoteFileRequest(path: "overflow.txt"), client: rejected) + try await until("global overflow response") { fixture.snapshot().replies.contains { $0.0 == rejected } } + try expect(!fixture.snapshot().drops.contains(rejected), "global file pressure disconnected a terminal") + try expect(fixture.snapshot().replies.last?.1["code"] as? String == "file-busy", "global overflow did not return busy") + try expect(WebRemoteFiles.gate.snapshot().paths.count == 8, "overflow started a ninth operation") + WebRemoteFiles.gate.release("0-a.txt") + try await until("one old ticket completes") { fixture.snapshot().tickets == 7 } + let admitted = fixture.addClient() + fixture.submit(WebRemoteFileRequest(path: "new.txt"), client: admitted) + try await until("replacement starts in freed slot") { WebRemoteFiles.gate.snapshot().paths.contains("new.txt") } + try expect(fixture.snapshot().tickets == 8, "completed ticket did not release exactly one slot") + WebRemoteFiles.gate.releaseAll() + try await until("all tickets complete") { fixture.snapshot().tickets == 0 } + try expect(WebRemoteFiles.gate.snapshot().maximum <= 8, "actual concurrent I/O exceeded eight") + try expect(fixture.snapshot().replies.count == 2 && fixture.snapshot().replies.last?.0 == admitted, + "late I/O replied to a removed client") + try expect(fixture.snapshot().suppressed == 8, "missing-client transport no-op was not exercised") + } + /// Missing workspace state releases admission without reading; takes no inputs; throws on mismatch. + static func missingStore() async throws { + WorkspaceStore.current = nil + let client = fixture.addClient() + fixture.submit(WebRemoteFileRequest(path: "missing-store.txt"), client: client) + await settle() + try await until("missing store releases ticket") { fixture.snapshot().tickets == 0 } + try expect(WebRemoteFiles.gate.snapshot().paths.isEmpty, "missing store initiated I/O") + } + /// Missing and mismatched roots release tickets with correlated errors; takes no inputs; throws on mismatch. + static func invalidRoots() async throws { + let client = fixture.addClient() + WorkspaceStore.current?.root = nil + fixture.submit(WebRemoteFileRequest(path: "missing-root.txt"), client: client) + try await until("missing root response") { fixture.snapshot().replies.count == 1 } + try expect(fixture.snapshot().tickets == 0, "missing root leaked its ticket") + try expect(fixture.snapshot().replies.last?.1["code"] as? String == "files-unavailable", "wrong missing-root error") + WorkspaceStore.current?.root = "/other" + fixture.submit(WebRemoteFileRequest(path: "changed-root.txt"), client: client) + try await until("changed root response") { fixture.snapshot().replies.count == 2 } + try expect(fixture.snapshot().tickets == 0, "invalid root leaked its ticket") + try expect(fixture.snapshot().replies.last?.1["code"] as? String == "file-root-changed", "wrong changed-root error") + try expect(WebRemoteFiles.gate.snapshot().paths.isEmpty, "invalid roots reached file I/O") + } + /// Dead clients must not begin I/O before or after the main-queue hop; takes no inputs; throws on mismatch. + static func disappearingClient() async throws { + fixture.submit(WebRemoteFileRequest(path: "absent.txt"), client: UUID()) + try expect(fixture.snapshot().tickets == 0, "absent client received an admission ticket") + let client = fixture.addClient() + fixture.submit(WebRemoteFileRequest(path: "disappeared.txt"), client: client) + fixture.disconnect(client) + await settle() + try await until("disappeared client releases ticket") { fixture.snapshot().tickets == 0 } + try expect(WebRemoteFiles.gate.snapshot().paths.isEmpty, "disappeared client initiated I/O") + try expect(fixture.snapshot().replies.isEmpty, "disappeared client received a reply") + } + /// Every read/list success and error path releases its ticket; takes no inputs; throws on mismatch. + static func completionPaths() async throws { + let client = fixture.addClient() + let cases: [(String, Bool, String)] = [ + ("read.txt", true, "fileContent"), ("read.html", true, "fileContent"), + ("image.png", true, "fileImage"), ("directory", false, "fileList"), + ("fail.txt", true, "fileError"), ("fail.png", true, "fileError"), + ("fail-directory", false, "fileError"), + ] + for (index, item) in cases.enumerated() { + let request = WebRemoteFileRequest(path: item.0, read: item.1) + fixture.submit(request, client: client) + try await until("I/O starts: \(item.0)") { WebRemoteFiles.gate.snapshot().paths.contains(item.0) } + WebRemoteFiles.gate.release(item.0) + try await until("I/O responds: \(item.0)") { fixture.snapshot().replies.count == index + 1 } + try expect(fixture.snapshot().tickets == 0, "completion leaked ticket: \(item.0)") + try expect(fixture.snapshot().replies.last?.1["type"] as? String == item.2, "wrong response branch: \(item.0)") + } + } + /// A changed root during I/O is rejected and releases its ticket; takes no inputs; throws on mismatch. + static func changedRootDuringRead() async throws { + let client = fixture.addClient() + fixture.submit(WebRemoteFileRequest(path: "late.txt"), client: client) + try await until("late read blocks") { WebRemoteFiles.gate.snapshot().running == 1 } + WorkspaceStore.current?.root = "/other" + WebRemoteFiles.gate.release("late.txt") + try await until("changed-root response") { fixture.snapshot().replies.count == 1 } + try expect(fixture.snapshot().tickets == 0, "revalidation leaked ticket") + try expect(fixture.snapshot().replies.last?.1["code"] as? String == "file-root-changed", "late read bypassed root revalidation") + } +} +@main struct Runner { + /// Run isolated production-method regressions; takes no inputs; exits nonzero on failure. + @MainActor static func main() async { + let tests: [(String, () async throws -> Void)] = [ + ("per-client file I/O cap", AdmissionTests.clientLimit), + ("global cap survives disconnect/stop/restart", AdmissionTests.globalLimitSurvivesRestart), + ("missing WorkspaceStore releases ticket", AdmissionTests.missingStore), + ("invalid roots release tickets", AdmissionTests.invalidRoots), + ("clients disappearing before I/O release tickets", AdmissionTests.disappearingClient), + ("all read/list completion paths release tickets", AdmissionTests.completionPaths), + ("root revalidation releases tickets", AdmissionTests.changedRootDuringRead), + ] + var failures = 0 + for (name, test) in tests { + AdmissionTests.setup() + do { try await test(); print("PASS: \(name)") } + catch { failures += 1; print("FAIL: \(name): \(error)") } + await AdmissionTests.cleanup() + } + print("Executed \(tests.count) Web file-admission cases; \(failures) failures") + exit(failures == 0 ? 0 : 1) + } +} +`; +const directory = fs.mkdtempSync(path.join(os.tmpdir(), "glint-web-file-admission-")); +try { + const swift = path.join(directory, "Admission.swift"); + fs.writeFileSync(swift, fixture.replace("ACCESS", source.slice(start, end)).replace("REQUESTERROR", requestError).replace("FILECODES", errorCodes)); + const binary = path.join(directory, "file-admission-tests"); + const compile = spawnSync("swiftc", ["-swift-version", "5", "-parse-as-library", "-module-cache-path", path.join(os.tmpdir(), "glint-file-admission-cache"), swift, "-o", binary], { encoding: "utf8", timeout: 60000 }); + process.stdout.write(compile.stdout ?? ""); + process.stderr.write(compile.stderr ?? ""); + if (compile.error) throw compile.error; + if (compile.status !== 0) process.exitCode = compile.status ?? 1; + else { + const run = spawnSync(binary, [], { encoding: "utf8", timeout: 20000 }); + process.stdout.write(run.stdout ?? ""); + process.stderr.write(run.stderr ?? ""); + if (run.error) throw run.error; + process.exitCode = run.status ?? 1; + } +} finally { + fs.rmSync(directory, { recursive: true, force: true }); +} diff --git a/scripts/test-web-remote-files.mjs b/scripts/test-web-remote-files.mjs index 59a8b62c..39ff0e95 100644 --- a/scripts/test-web-remote-files.mjs +++ b/scripts/test-web-remote-files.mjs @@ -27,13 +27,17 @@ function harness() { const elements = Object.fromEntries([ "filesPanel", "filesUp", "filesLocation", "filesList", "filesPreviewName", "filesContent", "filesImageViewport", "filesImage", "filesHTML", "filesSourceToggle", "filesMessage", "openFiles", + "emptyState", "terminal", ].map(name => [name, element()])); const context = vm.createContext({ elements, sent, document: { createElement: element, querySelector: element }, imageZoom: { reset() {} }, + terminal: { reset() {}, write(_data, complete) { complete?.(); }, focus() {} }, + decodeBase64: data => Buffer.from(data, "base64"), mobileSidebarLayout: { matches: false }, send: message => sent.push(message), t: key => key, + clearTimeout() {}, setStatus() {}, applyBrand() {}, applyTheme() {}, renderState() {}, chooseInitialPane() {}, fitTerminal() {}, }); vm.runInContext(` @@ -41,11 +45,14 @@ function harness() { let fileRequestSequence = 0, fileListRequest = "", fileContentRequest = ""; let fileDirectory = "", filePath = "", htmlSource = "", htmlSourceShown = false; let authenticated = true, selectedPane = "pane"; + let controllingPane = "pane", paneRetryTimer = null, paneRetryCount = 0, paneRetryLimit = 5; + let autoFocusTerminal = false; let lastState = { workspaces: [{ id: "ws", panes: [{ id: "pane", cwd: "/A" }] }] }; `, context); for (const name of [ "handleMessage", "fileErrorLabel", "sandboxedHTML", "clearFilePreview", "matchesFileResponse", "updateFilesButton", "updateFileLocation", "openFiles", "closeFiles", "browseFiles", "renderFiles", + "handleError", "errorLabel", ]) vm.runInContext(shippedFunction(name), context); const run = code => vm.runInContext(code, context); const deliver = message => context.handleMessage(JSON.stringify(message)); @@ -151,3 +158,62 @@ test("errors for an obsolete read cannot clear the current preview", () => { assert.equal(h.elements.filesContent.textContent, "new"); assert.equal(h.sent.at(-1).request, fresh.request); }); + +// A rejected file operation must leave the live terminal usable and permit a +// fresh request instead of requiring a transport reconnect. +test("busy and oversized file errors allow a successful retry", () => { + for (const [code, label] of [["file-busy", "file_busy"], ["response-too-large", "response_too_large"]]) { + const h = harness(); + const read = h.click(); + h.deliver({ ...read, type: "fileError", code }); + assert.equal(h.elements.filesMessage.textContent, label); + assert.equal(h.run("authenticated && controllingPane === 'pane'"), true); + const retry = h.click(); + assert.notEqual(retry.request, read.request); + h.deliver({ ...retry, type: "fileContent", content: "retried", format: "text" }); + assert.equal(h.elements.filesContent.textContent, "retried"); + } +}); + +test("coalesced send pressure releases pending file operations without disabling the terminal", () => { + const h = harness(); + h.click(); + h.run("fileListRequest = 'pending-list'"); + h.deliver({ type: "error", code: "send-queue-busy" }); + assert.equal(h.run("fileListRequest === '' && fileContentRequest === ''"), true); + assert.equal(h.run("authenticated && controllingPane === 'pane'"), true); + assert.equal(h.elements.filesMessage.textContent, "send_queue_busy"); +}); + +test("oversized snapshots stop automatic retries without closing the connection", () => { + const h = harness(); + h.deliver({ type: "error", code: "response-too-large", pane: "pane" }); + assert.equal(h.run("authenticated && selectedPane === 'pane' && controllingPane === ''"), true); + assert.equal(h.run("paneRetryCount === paneRetryLimit"), true); +}); + +for (const [label, fileErrors] of [["ordinary response pressure", 0], ["a saturated file-error queue", 32]]) { + test(`snapshot busy after ${label} revokes old control until a successful retry`, () => { + const h = harness(); + h.deliver({ type: "snapshot", pane: "pane", data: "b2xk" }); + const request = h.click(); + for (let index = 0; index < fileErrors; index += 1) { + h.deliver({ ...request, type: "fileError", code: "file-busy" }); + } + assert.equal(h.run("controllingPane"), "pane"); + h.deliver({ type: "error", code: "snapshot-busy", pane: "pane" }); + assert.equal(h.run("controllingPane"), ""); + assert.equal(h.run("authenticated"), true); + h.deliver({ type: "snapshot", pane: "pane", data: "bmV3" }); + assert.equal(h.run("controllingPane"), "pane"); + }); +} + +test("a rejected snapshot for an old pane cannot revoke the newly selected pane", () => { + const h = harness(); + h.deliver({ type: "snapshot", pane: "pane", data: "bmV3" }); + for (const code of ["snapshot-busy", "response-too-large"]) { + h.deliver({ type: "error", code, pane: "previous-pane" }); + assert.equal(h.run("controllingPane"), "pane"); + } +}); diff --git a/scripts/test-web-remote-lifecycle.mjs b/scripts/test-web-remote-lifecycle.mjs new file mode 100644 index 00000000..31f2bbe6 --- /dev/null +++ b/scripts/test-web-remote-lifecycle.mjs @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const source = fs.readFileSync(path.join(root, 'Glint/WebRemote/WebRemoteServer.swift'), 'utf8'); +const protocol = fs.readFileSync(path.join(root, 'Glint/WebRemote/WebRemoteProtocol.swift'), 'utf8'); +const baseline = process.argv.includes('--baseline'); +const old = baseline ? spawnSync('git', ['show', 'HEAD:Glint/WebRemote/WebRemoteServer.swift'], { cwd: root, encoding: 'utf8' }).stdout : source; + +/** Extract a complete real declaration; text and marker identify it; returns source through its closing brace. */ +function declaration(text, marker) { + const start = text.indexOf(marker); + assert.ok(start >= 0, `Missing ${marker}`); + const brace = text.indexOf('{', start); + let end = brace + 1; + let depth = 1; + for (; depth && end < text.length; end++) { + if (text[end] === '{') depth++; + if (text[end] === '}') depth--; + } + assert.equal(depth, 0); + return text.slice(start, end).replace(/^private /, ''); +} + +const buffers = source.slice(source.indexOf('struct WebRemoteOutputBuffer {'), source.indexOf('struct WebRemoteAuthThrottle {')); +const crypto = declaration(protocol, 'enum WebRemoteCrypto {'); +// Only transport completion is controlled. Queueing, encryption/counter use, +// cancellation, send deadlines and pane-selection state transitions are real. +// Execute captured deadline work items deterministically instead of sleeping. +const sender = ['func cancel()', 'func send(_ data: Data)', 'func sendTerminalOutput(', 'private func sendNextIfNeeded()'] + .map(marker => declaration(source, marker)).join('\n'); +const selector = declaration(old, 'private func selectPane('); +const http = ['private func finishHTTPConnection(', 'private func sendHTTP(', 'private func sendHTTPChunk('] + .map(marker => declaration(source, marker)).join('\n'); +const fixture = String.raw`import Foundation +import CryptoKit +import Security +import os +private let webRemoteLogger = Logger(subsystem: "app.glint.tests", category: "SendLifecycle") +struct Failure: Error { let message: String } +/// Check a regression; condition and label are inputs; throws on failure. +func expect(_ condition: Bool, _ label: String) throws { if !condition { throw Failure(message: label) } } +/// Await observable progress; label and condition are inputs; throws at a bounded deadline. +func until(_ label: String, _ condition: () -> Bool) async throws { + for _ in 0..<1000 { + if condition() { return } + try await Task.sleep(nanoseconds: 1_000_000) + } + throw Failure(message: "timeout: " + label) +} +enum NWProtocolWebSocket { + enum Opcode { case text, binary } + final class Metadata { + /// Stand in for frame metadata; input is opcode; returns metadata. + init(opcode: Opcode) {} + } +} +final class NWConnection { + final class ContentContext { + /// Stand in for transport context; inputs mirror Network; returns a context. + init(identifier: String, metadata: [NWProtocolWebSocket.Metadata]) {} + } + enum SendCompletion { case contentProcessed((Error?) -> Void) } + var sent: [Data] = [] + var completions: [(Error?) -> Void] = [] + var cancellations = 0 + var stateUpdateHandler: (() -> Void)? + /// Hold a send until the test completes it; arguments mirror Network; returns nothing. + func send(content: Data?, contentContext: ContentContext? = nil, isComplete: Bool = true, completion: SendCompletion) { + sent.append(content ?? Data()) + if case let .contentProcessed(block) = completion { completions.append(block) } + } + /// Record transport cancellation; no inputs or return value. + func cancel() { cancellations += 1 } +} +BUFFERS +CRYPTO +final class Client { + let connection = NWConnection() + let queue = DispatchQueue(label: "glint.tests.sender") + var sendQueue: DispatchQueue? + var sendGeneration: UUID? + var sendTimeout: DispatchWorkItem? + var cancelled = false + var sendInFlight = false + var outbound: WebRemoteOutboundBuffer + var pendingSelectionOutput = WebRemoteOutputBuffer(byteLimit: 0) + var encrypted = true + var s2cKey: SymmetricKey? = SymmetricKey(data: Data(repeating: 7, count: 32)) + var sendCounter: UInt64 = 0 + var authenticated = true + var subscribedPane: String? + var pendingPane: String? + var paneSelectionGeneration: UInt64 = 0 + /// Configure a real queue budget; input is total bytes; returns a sender. + init(bytes: Int = 16 * 1024 * 1024) { + outbound = WebRemoteOutboundBuffer(maxQueuedOutputBytes: min(512 * 1024, bytes / 2), maxQueuedBytes: bytes) + sendQueue = queue + } +SENDER +} +struct WebRemoteTerminalSize { var columns = 80; var rows = 24 } +struct WebRemoteTerminalSnapshot { let payload: Data; let outputSequence: UInt64 } +enum WebRemoteTerminalSnapshotResult { case success(WebRemoteTerminalSnapshot), failure(String) } +final class WorkspaceStore { + static var current: WorkspaceStore? = WorkspaceStore() + static var payload = Data() + /// Support the old selector baseline; inputs mirror Store; returns no focus failure. + func controlFocus(pane: String, activateApp: Bool) -> String? { nil } + /// Supply an in-memory grid; inputs mirror Store; completes without files or Ghostty. + func webRemoteTerminalSnapshot(pane: String, size: WebRemoteTerminalSize, + completion: @escaping (WebRemoteTerminalSnapshotResult) -> Void) { + completion(.success(WebRemoteTerminalSnapshot(payload: Self.payload, outputSequence: 0))) + } +} +final class SelectionFixture { + static let maxSelectionOutputBytes = 512 * 1024 + let queue = DispatchQueue(label: "glint.tests.selector") + let client = Client(bytes: 512) + let id = UUID() + var clients: [UUID: Client] = [:] + /// Register the isolated sender; no inputs; returns a selector. + init() { clients[id] = client; client.sendQueue = queue } + /// Invoke the real selector on its owning queue; input is pane; returns nothing. + func select(_ pane: String) { queue.sync { selectPane(pane, size: WebRemoteTerminalSize(), for: id) } } + /// Stand in for unrelated terminal size bookkeeping; inputs are size/client; returns nothing. + private func recordTerminalSizeLocked(_ size: WebRemoteTerminalSize, for client: Client) {} + /// Stand in for unrelated ingress subscription mirroring; no inputs or return value. + private func updateSubscribedPanesLocked() {} + /// Stand in for unrelated terminal size restoration; input is pane; returns nothing. + private func reconcileTerminalSizeLocked(for pane: String) {} + /// Fail if an unexpected grid failure is used; arguments mirror Store; returns nothing. + private func finishSelectionFailure(_ error: String, pane: String, generation: UInt64, clientID: UUID) { fatalError(error) } + /// Preserve failure on an unexpected terminal overflow; input is client ID; returns nothing. + private func dropSlowClientLocked(_ clientID: UUID) { client.cancel() } + /// Preserve the real client send decision; inputs are payload/client; returns acceptance. + private func sendJSON(_ object: [String: Any], to clientID: UUID) -> Bool { + guard let data = SafeJSON.data(object) else { return false } + return clients[clientID]?.send(data) ?? false + } +RECONCILE +CURRENT +SELECTOR +} +final class HTTPFixture { + let queue = DispatchQueue(label: "glint.tests.http-send") + let connection = NWConnection() + var httpConnections: [ObjectIdentifier: NWConnection] = [:] + var httpHeadersPending = Set() + var httpSendGenerations: [ObjectIdentifier: UUID] = [:] + var httpSendTimeouts: [ObjectIdentifier: DispatchWorkItem] = [:] + let httpSendIdleTimeout: TimeInterval = 0.15 + /// Track the fixture connection; no inputs; returns a response sender. + init() { httpConnections[ObjectIdentifier(connection)] = connection } +HTTP_METHODS +} +@main enum Tests { + /// Exercise real deadlines and snapshot decisions; no inputs; exits with regression status. + @MainActor static func main() async { + let tests: [(String, () async throws -> Void)] = [ + ("stalled WebSocket send closes and rejects its late completion", stalledWebSocket), + ("WebSocket completion cancels the old deadline and preserves nonces", progressingWebSocket), + ("HTTP progress outlives a total deadline", progressingHTTP), + ("stalled HTTP send closes and ignores late completion", stalledHTTP), + ("snapshot rejection clears subscription and permits retry", snapshotRetry), + ] + var failures = 0 + for (name, test) in tests { + do { try await test(); print("PASS: " + name) } + catch { failures += 1; print("FAIL: \(name): \(error)") } + } + print("Executed \(tests.count) send/snapshot lifecycle cases; \(failures) failures") + exit(failures == 0 ? 0 : 1) + } + /// A never-completed send expires once; no inputs; throws if late completion restarts it. + @MainActor static func stalledWebSocket() async throws { + let client = Client() + client.queue.sync { _ = client.send(Data("first".utf8)); _ = client.send(Data("second".utf8)) } + client.queue.sync { client.sendTimeout!.perform() } + try expect(client.queue.sync { client.cancelled }, "stalled sender did not time out") + client.queue.sync { client.connection.completions[0](nil) } + try expect(client.queue.sync { client.connection.sent.count == 1 && client.connection.cancellations == 1 }, "late send restarted a cancelled connection") + } + /// Completed sends invalidate old timers and advance GCM counters once; no inputs; throws on mismatch. + @MainActor static func progressingWebSocket() async throws { + let client = Client() + client.queue.sync { _ = client.send(Data("first".utf8)); _ = client.send(Data("second".utf8)) } + client.queue.sync { + let old = client.sendTimeout! + client.connection.completions[0](nil) + old.perform() + } + try expect(client.queue.sync { !client.cancelled && client.connection.sent.count == 2 }, "old deadline cancelled a progressing sender") + client.queue.sync { + let old = client.sendTimeout! + client.connection.completions[1](nil) + old.perform() + } + try expect(client.queue.sync { !client.cancelled && client.sendCounter == 2 }, "completed send retained a deadline or reused nonce") + let counters = client.queue.sync { client.connection.sent.compactMap { WebRemoteCrypto.counter(fromNonce: $0.prefix(12)) } } + try expect(counters == [0, 1], "wire counters were not monotonic") + client.queue.sync { client.cancel() } + } + /// Multiple chunks can take longer than one idle deadline; no inputs; throws on premature cancellation. + @MainActor static func progressingHTTP() async throws { + let http = HTTPFixture() + let data = Data(repeating: 1, count: 128 * 1024 + 1) + http.queue.sync { http.sendHTTP(data, to: http.connection) } + for index in 0..<3 { + http.queue.sync { + let old = http.httpSendTimeouts[ObjectIdentifier(http.connection)]! + http.connection.completions[index](nil) + old.perform() + } + } + try expect(http.queue.sync { http.connection.sent.map(\.count) == [65536, 65536, 1] }, "response chunks were not bounded") + try expect(http.queue.sync { http.connection.cancellations == 1 && http.httpConnections.isEmpty }, "completed response did not release connection") + } + /// A stopped HTTP write cannot resume from its callback; no inputs; throws on a leaked slot or send. + @MainActor static func stalledHTTP() async throws { + let http = HTTPFixture() + http.queue.sync { http.sendHTTP(Data(repeating: 1, count: 128 * 1024), to: http.connection) } + http.queue.sync { http.httpSendTimeouts[ObjectIdentifier(http.connection)]!.perform() } + try expect(http.queue.sync { http.connection.cancellations == 1 }, "stalled HTTP sender did not time out") + http.queue.sync { http.connection.completions[0](nil) } + try expect(http.queue.sync { http.connection.sent.count == 1 && http.httpConnections.isEmpty }, "late HTTP completion resumed old transfer") + } + /// Oversized snapshots cannot enable output, but a smaller retry can; no inputs; throws on mismatch. + @MainActor static func snapshotRetry() async throws { + let server = SelectionFixture() + WorkspaceStore.payload = Data(repeating: 1, count: 1024) + server.select("pane") + try await until("snapshot rejection") { server.queue.sync { server.client.pendingPane == nil } } + try expect(server.queue.sync { server.client.subscribedPane == nil && !server.client.cancelled }, "rejected snapshot subscribed or disconnected its pane") + server.queue.sync { server.client.connection.completions[0](nil) } + WorkspaceStore.payload = Data("small".utf8) + server.select("pane") + try await until("successful snapshot retry") { server.queue.sync { server.client.subscribedPane == "pane" } } + try expect(server.queue.sync { server.client.connection.sent.count == 2 }, "retry did not send a snapshot on the existing connection") + server.queue.sync { server.client.connection.completions[1](nil); server.client.cancel() } + } +} +`; +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'glint-send-lifecycle-')); +try { + const swift = fixture.replace('BUFFERS', buffers).replace('CRYPTO', crypto).replace('SENDER', sender) + .replace('SELECTOR', selector).replace('HTTP_METHODS', http) + .replace('RECONCILE', declaration(source, 'static func panesToReconcileWhenSelecting(')) + .replace('CURRENT', declaration(source, 'static func isCurrentPaneSelection(')); + fs.writeFileSync(path.join(dir, 'Tests.swift'), swift); + const executable = path.join(dir, 'test'); + const build = spawnSync('swiftc', ['-swift-version', '5', '-parse-as-library', '-module-cache-path', path.join(os.tmpdir(), 'glint-send-lifecycle-cache'), path.join(dir, 'Tests.swift'), path.join(root, 'Glint/Agent/SafeJSON.swift'), '-o', executable], { encoding: 'utf8', timeout: 60000 }); + process.stderr.write(build.stderr ?? ''); + if (build.status !== 0) process.exitCode = 1; + else { + const result = spawnSync(executable, [], { encoding: 'utf8', timeout: 10000 }); + process.stdout.write(result.stdout ?? ''); process.stderr.write(result.stderr ?? ''); + process.exitCode = result.status ?? 1; + } +} finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/scripts/test-web-remote-queue.mjs b/scripts/test-web-remote-queue.mjs new file mode 100644 index 00000000..7e4600ce --- /dev/null +++ b/scripts/test-web-remote-queue.mjs @@ -0,0 +1,197 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const source = fs.readFileSync(path.join(root, 'Glint/WebRemote/WebRemoteServer.swift'), 'utf8'); +const start = source.indexOf('struct WebRemoteOutboundBuffer {'); +const end = source.indexOf('\nstruct WebRemoteAuthThrottle', start); +if (start < 0 || end < 0) throw new Error('Outbound extraction markers changed'); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'glint-outbound-test-')); +/** Extract one production method; marker is its declaration; returns its body and signature. */ +function method(marker) { + const begin = source.indexOf(marker); + if (begin < 0) throw new Error('Missing client method: ' + marker); + const brace = source.indexOf('{', begin); + let depth = 1; + let finish = brace + 1; + for (; finish < source.length && depth; finish++) { + if (source[finish] === '{') depth++; + if (source[finish] === '}') depth--; + } + return source.slice(begin, finish).replace(/^private /, ''); +} +const client = String.raw` +final class SlowConnection { + var cancellations = 0 + /// Record cancellation; no inputs; returns nothing. + func cancel() { cancellations += 1 } +} +final class SlowClient { + var cancelled = false + var sendTimeout: DispatchWorkItem? + var sendGeneration: UUID? + var outbound = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 512 * 1024) + var pendingSelectionOutput = WebRemoteOutputBuffer(byteLimit: 32) + let connection = SlowConnection() + /// Simulate an already blocked sender; no inputs or result; never dequeues. + func sendNextIfNeeded() {} +` + method('func cancel() {') + '\n@discardableResult\n' + method('func send(_ data: Data)') + '\n}\n'; +const tests = String.raw` +/// Assert a regression condition; value and label are inputs; returns nothing or exits. +func check(_ value: Bool, _ label: String) { + guard value else { print("FAIL: \(label)"); exit(1) } + print("PASS: \(label)") +} +var buffer = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 512 * 1024) +let image = Data(repeating: 1, count: 7 * 1024 * 1024) +buffer.enqueueMessage(image) +check(buffer.next() != nil, "legal base64 image fits ordinary response budget") +for _ in 0..<32 { buffer.enqueueMessage(Data(repeating: 1, count: 1024 * 1024)) } +var bytes = 0 +while let item = buffer.next() { + if case let .message(data) = item { bytes += data.count } +} +check(bytes <= 16 * 1024 * 1024, "ordinary responses obey total byte budget") +buffer.removeAll() +for _ in 0..<1000 { buffer.enqueueMessage(Data()) } +var count = 0 +while buffer.next() != nil { count += 1 } +check(count <= 256, "zero-byte responses obey item budget") +check(buffer.enqueueTerminalOutput(Data(repeating: 2, count: 512 * 1024), pane: "a"), "terminal output fits its separate budget") +check(!buffer.enqueueTerminalOutput(Data([1]), pane: "a"), "terminal stream overflow is rejected") +buffer.removeAll() +check(buffer.enqueueTerminalOutput(Data([1]), pane: "a"), "cancel clears byte accounting") +var mixed = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 2, maxQueuedBytes: 10, maxQueuedItems: 3) +check(mixed.enqueueMessage(Data(repeating: 1, count: 8)), "ordinary message uses shared total") +check(!mixed.enqueueTerminalOutput(Data([1, 2, 3]), pane: "a"), "mixed queue cannot exceed shared bytes") +check(mixed.enqueueTerminalOutput(Data([1, 2]), pane: "a"), "mixed queue exact byte limit accepted") +check(!mixed.enqueueMessage(Data()), "ordinary messages obey shared item count") +_ = mixed.next() +check(mixed.enqueueMessage(Data(repeating: 1, count: 8)), "dequeue restores only consumed bytes") +mixed.removeAll() +check(mixed.enqueueMessage(Data(repeating: 1, count: 8)), "reset releases the full ordinary-response budget") +let slow = SlowClient() +for _ in 0..<3 { slow.send(image) } +check(!slow.cancelled && slow.connection.cancellations == 0, "temporary ordinary queue pressure preserves the terminal connection") +check(slow.outbound.next() != nil, "overflow preserves queued data for delivery") +slow.send(Data([1])) +check(!slow.cancelled, "ordinary queue overflow remains recoverable") +let large = SlowClient() +large.send(Data(repeating: 0x41, count: 17 * 1024 * 1024)) +check(!large.cancelled, "one oversized message does not force a reconnect loop") +var notices = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 8, maxQueuedBytes: 64) +let fileReply = try! JSONSerialization.data(withJSONObject: [ + "type": "fileImage", "workspace": "workspace", "pane": "pane", "root": "/allowed", + "path": "picture.png", "request": "request-7", "data": "payload", +]) +notices.rejectMessage(fileReply) +guard case let .message(notice)? = notices.next(), + let object = try! JSONSerialization.jsonObject(with: notice) as? [String: String] else { fatalError("missing rejection") } +check(object["type"] == "fileError" && object["code"] == "response-too-large", "oversized file produces a recoverable file error") +check(object["request"] == "request-7" && object["path"] == "picture.png" && object["root"] == "/allowed", + "rejection preserves request identity and root") +for _ in 0..<1000 { notices.rejectMessage(fileReply) } +var noticeCount = 0 +var noticeBytes = 0 +while let next = notices.next() { + if case let .message(data) = next { noticeCount += 1; noticeBytes += data.count } +} +check(noticeCount <= 33 && noticeBytes <= 64 * 1024 + 100, "error flood remains byte and item bounded") +check(notices.enqueueMessage(Data("snapshot".utf8)), "snapshot queues after notices drain") +check(notices.enqueueTerminalOutput(Data("ab".utf8), pane: "pane"), "snapshot's output queues behind it") +notices.rejectMessage(fileReply) +if case let .message(data)? = notices.next() { check(data == Data("snapshot".utf8), "older snapshots arrive before later rejection notices") } +else { fatalError("missing snapshot") } +if case let .terminalOutput(_, data)? = notices.next() { check(data == Data("ab".utf8), "notices do not reorder terminal bytes") } +else { fatalError("missing terminal output") } +if case let .message(data)? = notices.next(), + let error = try! JSONSerialization.jsonObject(with: data) as? [String: String] { + check(error["code"] == "response-too-large", "new rejection remains visible after older snapshot and output") +} else { fatalError("missing ordered rejection") } +var protectedBytes = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 4, maxQueuedBytes: 12, maxQueuedItems: 8) +for _ in 0..<4 { protectedBytes.enqueueMessage(Data(repeating: 1, count: 4)) } +check(protectedBytes.enqueueTerminalOutput(Data(repeating: 2, count: 4), pane: "pane"), + "ordinary byte pressure leaves terminal stream capacity") +var protectedItems = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 4, maxQueuedBytes: 12, maxQueuedItems: 4) +for _ in 0..<4 { protectedItems.enqueueMessage(Data()) } +check(protectedItems.enqueueTerminalOutput(Data([1]), pane: "pane"), + "ordinary item pressure leaves a terminal stream slot") +var interleaved = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 4, maxQueuedBytes: 16, maxQueuedItems: 3) +_ = interleaved.enqueueTerminalOutput(Data([1]), pane: "pane") +_ = interleaved.enqueueMessage(Data([2])) +_ = interleaved.enqueueMessage(Data([3])) +check(interleaved.enqueueTerminalOutput(Data([4]), pane: "pane"), "interleaved messages preserve a slot for the next terminal segment") +var snapshotBoundary = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 16, maxQueuedBytes: 128) +let failedSnapshot = try! JSONSerialization.data(withJSONObject: ["type": "snapshot", "pane": "pane", "data": String(repeating: "x", count: 200)]) +_ = snapshotBoundary.enqueueTerminalOutput(Data("old".utf8), pane: "pane") +snapshotBoundary.rejectMessage(failedSnapshot) +_ = snapshotBoundary.enqueueTerminalOutput(Data("new".utf8), pane: "pane") +if case let .terminalOutput(_, data)? = snapshotBoundary.next() { check(data == Data("old".utf8), "snapshot rejection prevents cross-boundary output coalescing") } +else { fatalError("missing old output") } +_ = snapshotBoundary.next() +if case let .terminalOutput(_, data)? = snapshotBoundary.next() { check(data == Data("new".utf8), "post-boundary bytes follow the snapshot error") } +else { fatalError("missing new output") } +var fileBoundary = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 16, maxQueuedBytes: 128) +_ = fileBoundary.enqueueTerminalOutput(Data("old".utf8), pane: "pane") +fileBoundary.rejectMessage(fileReply) +_ = fileBoundary.enqueueTerminalOutput(Data("new".utf8), pane: "pane") +if case let .terminalOutput(_, data)? = fileBoundary.next() { check(data == Data("oldnew".utf8), "independent file errors do not fragment or revoke the terminal stream") } +else { fatalError("missing terminal output") } +var saturated = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 8, maxQueuedBytes: 512, maxQueuedItems: 4) +let oldSnapshot = try! JSONSerialization.data(withJSONObject: ["type": "snapshot", "pane": "pane", "data": "old"]) +let freshSnapshot = try! JSONSerialization.data(withJSONObject: ["type": "snapshot", "pane": "pane", "data": "fresh"]) +_ = saturated.enqueueMessage(oldSnapshot) +_ = saturated.enqueueMessage(Data("state-one".utf8)) +_ = saturated.enqueueMessage(Data("state-two".utf8)) +for _ in 0..<32 { saturated.rejectMessage(fileReply) } +saturated.rejectMessage(freshSnapshot) +var ordered: [[String: String]] = [] +if case let .message(data)? = saturated.next() { + ordered.append((try! JSONSerialization.jsonObject(with: data)) as! [String: String]) +} +check(saturated.enqueueMessage(freshSnapshot), "a later successful snapshot fits after old data drains") +while let next = saturated.next() { + if case let .message(data) = next, + let value = (try? JSONSerialization.jsonObject(with: data)) as? [String: String] { ordered.append(value) } +} +let oldPosition = ordered.firstIndex { $0["type"] == "snapshot" && $0["data"] == "old" } +let rejectionPosition = ordered.firstIndex { $0["code"] == "snapshot-busy" && $0["pane"] == "pane" } +let freshPosition = ordered.firstIndex { $0["type"] == "snapshot" && $0["data"] == "fresh" } +check(oldPosition != nil && rejectionPosition != nil && freshPosition != nil, + "saturated file notices cannot erase a pane's snapshot rejection") +check(oldPosition! < rejectionPosition! && rejectionPosition! < freshPosition!, + "old snapshot, control rejection and successful retry retain causal order") +var controlFlood = WebRemoteOutboundBuffer(maxQueuedOutputBytes: 8, maxQueuedBytes: 512) +for _ in 0..<1000 { controlFlood.rejectMessage(fileReply); controlFlood.rejectMessage(freshSnapshot) } +var controlNoticeCount = 0 +var controlNoticeBytes = 0 +var snapshotNoticeCount = 0 +while let next = controlFlood.next() { + if case let .message(data) = next { + controlNoticeCount += 1; controlNoticeBytes += data.count + let object = try! JSONSerialization.jsonObject(with: data) as! [String: String] + if object["code"] == "snapshot-busy" { snapshotNoticeCount += 1 } + } +} +check(controlNoticeCount <= 34 && controlNoticeBytes <= 64 * 1024 + 2200 && snapshotNoticeCount == 1, + "a snapshot error flood retains one bounded latest control notice") +let longPane = String(repeating: "\u{1}", count: 100_000) +controlFlood.rejectMessage(try! JSONSerialization.data(withJSONObject: ["type": "snapshot", "pane": longPane])) +if case let .message(data)? = controlFlood.next() { + check(data.count <= 2048, "the reserved snapshot notice has a fixed byte bound even for malformed metadata") +} else { fatalError("missing bounded snapshot notice") } +`; +try { + fs.writeFileSync(path.join(dir, 'main.swift'), 'import Foundation\n' + source.slice(source.indexOf('struct WebRemoteOutputBuffer {'), end) + client + tests); + const build = spawnSync('swiftc', ['-swift-version', '5', '-module-cache-path', path.join(dir, 'cache'), path.join(dir, 'main.swift'), '-o', path.join(dir, 'test')], { encoding: 'utf8', timeout: 60000 }); + process.stderr.write(build.stderr ?? ''); + if (build.status !== 0) process.exitCode = 1; + else { + const run = spawnSync(path.join(dir, 'test'), [], { encoding: 'utf8', timeout: 10000 }); + process.stdout.write(run.stdout ?? ''); process.stderr.write(run.stderr ?? ''); + process.exitCode = run.status ?? 1; + } +} finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/scripts/test-workspace-lifecycle.mjs b/scripts/test-workspace-lifecycle.mjs new file mode 100644 index 00000000..ed3822e6 --- /dev/null +++ b/scripts/test-workspace-lifecycle.mjs @@ -0,0 +1,447 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +// Exercise production archive/capture/flush/termination bodies without starting +// WorkspaceStore, AppKit, Ghostty, hooks, or the real Application Support path. +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const storeSource = fs.readFileSync(path.join(root, 'Glint/Workspace/WorkspaceStore.swift'), 'utf8'); +const surfaceSource = fs.readFileSync(path.join(root, 'Glint/Pane/GhosttySurfaceView.swift'), 'utf8'); + +/** Extract a production method; source and signature identify it; returns the unchanged body with test-visible access. */ +function method(source, signature) { + const at = source.indexOf(signature); + if (at < 0) throw new Error(`Missing production method ${signature}`); + const start = source.lastIndexOf('\n', at) + 1; + const end = source.indexOf('\n }', at); + if (end < 0) throw new Error(`Missing method boundary ${signature}`); + return source.slice(start, end + 6).replace(/\b(?:fileprivate|private) func /g, 'func '); +} + +const modelStart = storeSource.indexOf('enum SplitDirection:'); +const factoryStart = storeSource.indexOf('\nextension Workspace {', modelStart); +const factoryEnd = storeSource.indexOf(' /// The currently visible tab', factoryStart); +const marker = storeSource.indexOf('forName: NSApplication.willTerminateNotification'); +const observerStart = storeSource.lastIndexOf(' observerTokens.append(NotificationCenter.default.addObserver(', marker); +const observerEnd = storeSource.indexOf('\n })', marker) + '\n })'.length; +if ([modelStart, factoryStart, factoryEnd, marker, observerStart].some(index => index < 0)) { + throw new Error('Lifecycle extraction markers changed'); +} +const pruneAt = storeSource.indexOf(' // Drop scrollback snapshots for panes that no longer exist.'); +const pruneEnd = storeSource.indexOf(' // Block-based observers', pruneAt); +if (pruneAt < 0 || pruneEnd < 0) throw new Error('Startup prune markers changed'); +const source = `import Foundation +import Darwin +struct Color { + /// Provide UI color placeholder; hex is a color string; returns a placeholder. + init?(hex: String) {} + /// Provide UI color placeholder; parameters are RGB values; returns a placeholder. + init(red: Double, green: Double, blue: Double) {} +} +enum PaneAgentKind: String { case claude, codex, opencode, devin, omp, grok } +${storeSource.slice(modelStart, factoryStart)} +${storeSource.slice(factoryStart, factoryEnd)} +} +extension Workspace { var displayName: String { name } } +struct WorkspacePaneKey: Hashable { let workspace: UUID; let pane: PaneID } +var events: [String] = [] +var grids: [Int: String] = [:] +var exports = 0 +var failures = 0 +var scenarios = 0 +struct Grid { let ptr: UnsafeMutablePointer?; let len: Int } +/// Read a fake live grid; parameters match the C API; returns its current bytes. +func ghostty_surface_render_grid_json(_ surface: Int, _ q: String, _ a: Int, _ b: Int, _ n: UInt) -> Grid { + exports += 1 + let text = grids[surface] ?? "" + return Grid(ptr: strdup(text), len: text.utf8.count) +} +/// Release the fake C buffer; grid contains its allocation; returns no value. +func ghostty_string_free(_ grid: Grid) { free(grid.ptr) } +/// Hold the foreground process stable; surface is the fake handle; returns its PID. +func ghostty_surface_foreground_pid(_ surface: Int) -> Int32 { 77 } +enum ScrollbackArchive { + static var pending: [String: String] = [:] + static var saved: [String: String] = [:] + static var pruned: Set? + /// Queue a fake archive write; arguments match production; returns no value. + static func writeRendered(id: String, gridJSON: Data, maxLines: Int, priorHistory: String?, restoreMarker: String) { + events.append("snapshot:" + id) + pending[id] = String(decoding: gridJSON, as: UTF8.self) + } + /// Map fake pane keys to archive IDs; input is a pane key; returns its test ID. + static func fileID(forPaneKey key: String) -> String { key } + /// Execute real callers' pruning policy; input is live IDs; returns no value. + static func prune(keeping ids: Set) { + events.append("prune"); pruned = ids + saved = saved.filter { ids.contains($0.key) } + } + /// Complete queued writes; no parameters; returns no value. + static func drain() { + events.append("drain") + saved.merge(pending) { _, new in new } + pending = [:] + } +} +struct RemoteContext { let target: String; let port: Int?; let remotePath: String? } +final class GhosttySurfaceView { + let surface: Int? + let scrollbackID: String? + let maxScrollbackLines = 3000 + let restoredHistory: String? = nil + static let restoreMarkerText = "restored" + var scrollbackNeedsFlush = true + var scrollbackLastFlushedPid: pid_t = 0 + var process = "codex" + var remoteReviewContext: RemoteContext? = nil + var remoteHost: String? = nil + /// Create an isolated surface; handle and ID identify its fake grid; returns a surface. + init(handle: Int, id: String) { surface = handle; scrollbackID = id } + /// Record release; no inputs; returns no value. + deinit { events.append("release:" + (scrollbackID ?? "")) } + /// Read final cwd; no parameters; returns the fake terminal directory. + func currentCwd() -> String? { events.append("capture:" + (scrollbackID ?? "")); return "/final/cwd" } + /// Read the live process; no parameters; returns the fixture's current process name. + func foregroundProcessName() -> String? { process } +${method(surfaceSource, 'func flushScrollbackToDisk(')} +${method(surfaceSource, 'func noteForegroundPidForScrollback(')} +} +enum NSSound { + /// Record the last-workspace refusal; no parameters; returns no value. + static func beep() { events.append("beep") } +} +enum NSApplication { static let willTerminateNotification = Notification.Name("lifecycle-test-terminate") } +enum ModalResponse { case alertFirstButtonReturn, alertSecondButtonReturn } +final class NSAlert { + enum Style { case warning } + var messageText = "" + var informativeText = "" + var alertStyle = Style.warning + static var decisions: [() -> ModalResponse] = [] + static var prompts: [String] = [] + /// Record a test button; input is its label; returns no value. + func addButton(withTitle title: String) {} + /// Drive only modal input; no inputs; returns the next fixture decision. + func runModal() -> ModalResponse { + Self.prompts.append(informativeText) + guard !Self.decisions.isEmpty else { return .alertSecondButtonReturn } + return Self.decisions.removeFirst()() + } +} +final class UserDefaults { + static let standard = UserDefaults() + /// Avoid global preferences; input is a key; returns nil to use the production default. + func object(forKey key: String) -> Any? { nil } +} +enum Persistence { + static var requiresLoadRetry = false + static var next: PersistedState? + static var reads = 0 + static var blockedSaves = 0 + static var saved: [PersistedState] = [] + /// Model the separately disk-tested load boundary; no inputs; returns the next snapshot and only then permits writes. + static func load() -> PersistedState? { + reads += 1 + guard let next else { return nil } + requiresLoadRetry = false + return next + } + /// Model the separately disk-tested write boundary; input is the state; returns no value. + static func save(_ state: PersistedState) { + if requiresLoadRetry { blockedSaves += 1 } else { saved.append(state) } + } +} +enum Status { case thinking, tool, compacting, needsPermission, idle } +struct AgentState { let status: Status } +final class Store { + var workspaces: [Workspace] = [] + var selectedWorkspaceID: UUID? + var sidebarCollapsed = false + var pendingInitialInput: [WorkspacePaneKey: String] = [:] + var gitStatuses: [UUID: Int] = [:] + var webRemoteControlledPanes = Set() + var surfaceViews: [WorkspacePaneKey: GhosttySurfaceView] = [:] + var paneAgentState: [WorkspacePaneKey: AgentState] = [:] + var paneProcesses: [WorkspacePaneKey: String] = [:] + var restoreTerminalScrollback = true + var observerTokens: [NSObjectProtocol] = [] + var persisted: [Workspace] = [] + var activeWorkspaces: [Workspace] { workspaces.filter { !$0.archived } } + static var informativePrompts: [String] = [] + static var allowConfirmation = true + static var duringConfirmation: (() -> Void)? + static let benignShells: Set = ["zsh", "bash", "sh", "fish"] + /// Match known agent process names; name is the process; returns its agent kind or nil. + static func agentKind(forProcessName name: String) -> PaneAgentKind? { PaneAgentKind(rawValue: name) } + /// Match agent names for busy detection; name is the process; returns its agent kind or nil. + static func agentKind(named name: String) -> PaneAgentKind? { agentKind(forProcessName: name) } + /// Classify shell names; name is the process; returns whether it is benign. + static func isBenignShellProcessName(_ name: String) -> Bool { benignShells.contains(name) } + /// Stand in for the modal only; arguments are prompt text and preference key; returns the fixture response. + static func confirmDestruction(message: String, informative: String, confirmTitle: String, suppressionKey: String) -> Bool { + informativePrompts.append(informative) + events.append("confirm:" + suppressionKey) + let reentry = duringConfirmation + duringConfirmation = nil + reentry?() + return allowConfirmation + } + /// Record badge cleanup; keys identify archived panes; returns no value. + func clearDockBadges(for keys: [WorkspacePaneKey]) { events.append("clear-badges") } + /// Record termination badge cleanup; no parameters; returns no value. + func clearDockBadge() { events.append("clear-badge") } + /// Record a process update; key and name identify it; returns no value. + func logProcessChange(key: WorkspacePaneKey, name: String) {} + /// Stand in for unrelated agent presentation; key and processName are inputs; returns no value. + func reconcileAgentState(key: WorkspacePaneKey, processName: String) {} + /// Record persistence order and contents; no parameters; returns no value. + func persist() { + events.append("persist"); persisted = workspaces + Persistence.save(PersistedState(workspaces: workspaces, selectedWorkspaceID: selectedWorkspaceID, sidebarCollapsed: sidebarCollapsed)) + } + /// Record watcher reconciliation; no inputs; returns no value. + func syncGitWatchers() { events.append("watchers") } + /// Record status loading; no inputs; returns no value. + func refreshAllGitStatuses() { events.append("git-status") } + /// Clear unrelated live control presentation; no inputs; returns no value. + func clearExternalControlActivity() { events.append("clear-external") } + /// Run the actual startup-prune block; no inputs; returns no value. + func pruneStartupScrollback() { +${storeSource.slice(pruneAt, pruneEnd)} + } +${method(storeSource, 'func offerSavedSessionRecovery(')} + /// Remove temporary notification observers; no parameters; returns no value. + deinit { for token in observerTokens { NotificationCenter.default.removeObserver(token) } } +${method(storeSource, 'func paneNeedsCloseConfirmation(')} +${method(storeSource, 'func archiveWorkspace(')} +${method(storeSource, 'func flushScrollback(')} +${method(storeSource, 'func captureCwdsFromLiveSurfaces(')} +${method(storeSource, 'func captureSurfaceState(from view:')} +${method(storeSource, 'func captureSurfaceState(for key:')} + /// Register the actual production termination observer; no parameters; returns no value. + func installTerminationObserver() { +${storeSource.slice(observerStart, observerEnd)} + } +} +/// Assert fixture behavior; condition and message are inputs; returns no value. +func check(_ condition: Bool, _ message: String) { + if !condition { failures += 1; print("FAIL: " + message) } +} +/// Run a lifecycle scenario; name and body identify it; returns no value. +func scenario(_ name: String, _ body: () -> Void) { + scenarios += 1 + events = []; ScrollbackArchive.saved = [:]; ScrollbackArchive.pending = [:] + Store.duringConfirmation = nil; Store.informativePrompts = [] + NSAlert.decisions = []; NSAlert.prompts = [] + Persistence.requiresLoadRetry = false; Persistence.next = nil; Persistence.reads = 0; Persistence.blockedSaves = 0; Persistence.saved = [] + ScrollbackArchive.pruned = nil + let before = failures + body() + print("\\(failures == before ? "PASS" : "FAIL"): \\(name)") +} +/// Build two live workspaces; process controls the first pane; returns the store with clean previous snapshots. +func fixture(process: String = "codex") -> Store { + let store = Store() + var first = Workspace.fresh(name: "Target", accentHex: "5E5CE6", symbol: "T") + first.panes[PaneID(value: 0)]?.sessionIds = ["codex": "keep-session", "claude": "stale-session"] + first.panes[PaneID(value: 0)]?.codexHome = "/custom/codex" + let second = Workspace.fresh(name: "Other", accentHex: "5E5CE6", symbol: "O") + store.workspaces = [first, second]; store.selectedWorkspaceID = first.id + for (index, workspace) in store.workspaces.enumerated() { + let handle = index + 1 + let id = String(handle) + grids[handle] = "before-" + id + let view = GhosttySurfaceView(handle: handle, id: id) + view.process = index == 0 ? process : "zsh" + store.surfaceViews[WorkspacePaneKey(workspace: workspace.id, pane: PaneID(value: 0))] = view + view.flushScrollbackToDisk() + } + ScrollbackArchive.drain(); events = [] + grids[1] = "final-output-1"; grids[2] = "final-output-2" + return store +} +scenario("busy archive cancellation keeps terminal and state") { + let store = fixture(); Store.allowConfirmation = false + let target = store.workspaces[0].id + let key = WorkspacePaneKey(workspace: target, pane: PaneID(value: 0)) + store.archiveWorkspace(target) + check(store.surfaceViews[key] != nil && !store.workspaces[0].archived, "cancel must retain terminal and workspace") + check(store.selectedWorkspaceID == target && store.persisted.isEmpty, "cancel must preserve selection and saved state") + check(events.contains(where: { $0.hasPrefix("confirm:") }), "busy archive must ask") + check(!events.contains("capture:1") && !events.contains("drain"), "cancel must not begin destructive preparation") +} +scenario("one busy pane gets singular archive confirmation") { + let store = fixture(); Store.allowConfirmation = false + store.archiveWorkspace(store.workspaces[0].id) + check(Store.informativePrompts == ["1 of its panes still has something running; archiving will terminate its processes."], "one-pane prompt must use singular grammar") +} +scenario("multiple busy panes get formatted plural archive confirmation") { + let store = fixture(); Store.allowConfirmation = false + let target = store.workspaces[0].id + let pane = PaneID(value: 1) + store.workspaces[0].panes[pane] = Pane(id: pane, title: "Second") + store.paneProcesses[WorkspacePaneKey(workspace: target, pane: pane)] = "vim" + store.archiveWorkspace(target) + check(Store.informativePrompts == ["2 of its panes still have something running; archiving will terminate their processes."], "multiple-pane prompt must format Int64 without truncation") +} +scenario("confirmed archive captures final metadata and drains before release") { + let store = fixture(); Store.allowConfirmation = true + let target = store.workspaces[0].id + store.archiveWorkspace(target) + check(store.workspaces[0].archived && store.surfaceViews.count == 1, "only archived terminals should release") + let pane = store.workspaces[0].panes[PaneID(value: 0)]! + check(pane.workingDirectory == "/final/cwd" && pane.lastAgent == "codex", "archive must capture final cwd and agent") + check(pane.sessionIds == ["codex": "keep-session"] && pane.codexHome == "/custom/codex", "archive must reconcile and retain agent resume state") + check(ScrollbackArchive.saved["1"] == "final-output-1", "archive must force latest grid with unchanged PID") + check(ScrollbackArchive.saved["2"] == "before-2", "archive must leave unrelated terminal snapshot alone") + let drained = events.firstIndex(of: "drain"), released = events.firstIndex(of: "release:1") + check(drained != nil && released != nil && drained! < released!, "archive must drain before releasing") + check(events.last == "persist" && store.persisted.first?.archived == true, "archive must persist its final state") + check(store.selectedWorkspaceID == store.workspaces[1].id, "archive should select next active workspace") +} +scenario("deleting target during confirmation cannot archive its survivor") { + let store = fixture(); Store.allowConfirmation = true + let target = store.workspaces[0].id + Store.duringConfirmation = { store.workspaces.removeAll { $0.id == target } } + store.archiveWorkspace(target) + check(store.workspaces.count == 1 && !store.workspaces[0].archived, "deleted target must not redirect archive to its survivor") + check(!events.contains("capture:1") && !events.contains("drain") && store.persisted.isEmpty, "missing target must abort before final capture or release") +} +scenario("reordering during confirmation archives the identified workspace") { + let store = fixture(); Store.allowConfirmation = true + let target = store.workspaces[0].id, other = store.workspaces[1].id + Store.duringConfirmation = { store.workspaces.reverse() } + store.archiveWorkspace(target) + check(store.workspaces.first(where: { $0.id == target })?.archived == true, "reorder must preserve target identity") + check(store.workspaces.first(where: { $0.id == other })?.archived == false && store.selectedWorkspaceID == other, "reorder must leave other workspace active and selected") +} +scenario("confirmation cannot archive the last remaining active workspace") { + let store = fixture(); Store.allowConfirmation = true + let target = store.workspaces[0].id + Store.duringConfirmation = { store.workspaces[1].archived = true } + store.archiveWorkspace(target) + check(!store.workspaces[0].archived && store.activeWorkspaces.count == 1, "changed active count must prevent final workspace archive") + check(store.surfaceViews.count == 2 && !events.contains("capture:1") && store.persisted.isEmpty, "last active workspace must keep its surface untouched") +} +scenario("target already archived during confirmation is not archived twice") { + let store = fixture(); Store.allowConfirmation = true + let target = store.workspaces[0].id + Store.duringConfirmation = { store.workspaces[0].archived = true } + store.archiveWorkspace(target) + check(!events.contains("capture:1") && !events.contains("drain") && store.persisted.isEmpty, "already archived target must abort before capture and release") +} +scenario("idle archive clears completed session without confirmation") { + let store = fixture(process: "zsh"); Store.allowConfirmation = false + store.archiveWorkspace(store.workspaces[0].id) + let pane = store.workspaces[0].panes[PaneID(value: 0)]! + check(store.workspaces[0].archived && !events.contains(where: { $0.hasPrefix("confirm:") }), "idle archive should proceed without prompt") + check(pane.lastAgent == nil && pane.sessionIds.isEmpty && pane.codexHome == nil, "archive must capture completed agent state") +} +scenario("last active workspace cannot archive") { + let store = fixture(); store.workspaces.removeLast() + store.archiveWorkspace(store.workspaces[0].id) + check(!store.workspaces[0].archived && events == ["beep"], "last active workspace must stay live") +} +scenario("disabled scrollback does not create a final snapshot") { + let store = fixture(process: "zsh"); store.restoreTerminalScrollback = false + store.archiveWorkspace(store.workspaces[0].id) + check(ScrollbackArchive.saved["1"] == "before-1" && !events.contains("snapshot:1"), "disabled feature must not capture scrollback") +} +scenario("daily clean flush still avoids grid serialization") { + let store = fixture(); let before = exports + store.flushScrollback() + check(exports == before && ScrollbackArchive.pending.isEmpty, "normal flush should retain dirty optimization") +} +scenario("termination forces latest grids before drain") { + let store = fixture(); store.installTerminationObserver() + NotificationCenter.default.post(name: NSApplication.willTerminateNotification, object: nil) + check(ScrollbackArchive.saved["1"] == "final-output-1" && ScrollbackArchive.saved["2"] == "final-output-2", "termination must save output that arrived with unchanged PID and no input") + check(events.last == "drain" && store.persisted.first?.panes[PaneID(value: 0)]?.workingDirectory == "/final/cwd", "termination must drain and persist captured state") +} +/// Construct a recoverable snapshot; name identifies it; returns a saved state with valid selection. +func restoredSnapshot(_ name: String = "Restored") -> PersistedState { + let workspace = Workspace.fresh(name: name, accentHex: "5E5CE6", symbol: "R") + return PersistedState(workspaces: [workspace], selectedWorkspaceID: workspace.id, sidebarCollapsed: true) +} +scenario("read-failure startup never prunes original session history") { + let store = fixture(); Persistence.requiresLoadRetry = true + ScrollbackArchive.saved["original-history"] = "last saved output" + store.pruneStartupScrollback() + check(ScrollbackArchive.saved["original-history"] == "last saved output" && ScrollbackArchive.pruned == nil, "temporary models must not prune saved history") +} +scenario("continuing a temporary session retains terminals and blocks autosave") { + let store = fixture(); let ids = store.workspaces.map(\\.id) + Persistence.requiresLoadRetry = true; Persistence.next = restoredSnapshot() + NSAlert.decisions = [{ .alertSecondButtonReturn }] + store.offerSavedSessionRecovery(); store.persist() + check(store.workspaces.map(\\.id) == ids && store.surfaceViews.count == 2, "cancel must preserve temporary terminals") + check(Persistence.reads == 0 && Persistence.blockedSaves == 1 && Persistence.saved.isEmpty, "cancel must not load or overwrite the original") +} +scenario("failed recovery read keeps protection and permits another decision") { + let store = fixture(); Persistence.requiresLoadRetry = true + NSAlert.decisions = [{ .alertFirstButtonReturn }, { .alertSecondButtonReturn }] + store.offerSavedSessionRecovery(); store.persist() + check(NSAlert.prompts.count == 2 && Persistence.reads == 1, "failed retry must remain recoverable") + check(store.surfaceViews.count == 2 && Persistence.blockedSaves == 1 && Persistence.saved.isEmpty, "read failure must retain terminals and protect saves") +} +scenario("confirmed recovery adopts state after draining temporary terminals") { + let store = fixture(); let restored = restoredSnapshot() + Persistence.requiresLoadRetry = true; Persistence.next = restored + store.webRemoteControlledPanes = Set(store.surfaceViews.keys) + store.pendingInitialInput = [store.surfaceViews.keys.first!: "do not replay"] + NSAlert.decisions = [{ .alertFirstButtonReturn }] + store.offerSavedSessionRecovery() + check(store.workspaces.map(\\.id) == restored.workspaces.map(\\.id) && store.selectedWorkspaceID == restored.selectedWorkspaceID && store.sidebarCollapsed, "retry must adopt the exact saved session") + check(store.surfaceViews.isEmpty && store.paneProcesses.isEmpty && store.paneAgentState.isEmpty && store.pendingInitialInput.isEmpty && store.webRemoteControlledPanes.isEmpty, "restoration must clear temporary live state") + let drained = events.firstIndex(of: "drain"), released = events.firstIndex(where: { $0.hasPrefix("release:") }) + check(drained != nil && released != nil && drained! < released!, "temporary snapshots must drain before surface release") + check(ScrollbackArchive.pruned?.count == restored.workspaces[0].panes.count && events.contains("watchers"), "restored IDs must drive pruning and watchers") + store.persist() + check(Persistence.saved.last?.workspaces.first?.id == restored.workspaces.first?.id, "saves may resume only with the adopted model") +} +scenario("new workspace during recovery confirmation requires another decision") { + let store = fixture(); let original = store.workspaces[0].id + Persistence.requiresLoadRetry = true; Persistence.next = restoredSnapshot() + NSAlert.decisions = [{ store.workspaces.append(Workspace.fresh(name: "New", accentHex: "5E5CE6", symbol: "N")); return .alertFirstButtonReturn }, { .alertSecondButtonReturn }] + store.offerSavedSessionRecovery() + check(Persistence.reads == 0 && NSAlert.prompts.count == 2 && store.workspaces.first?.id == original && store.workspaces.count == 3, "new workspaces must not be closed without a fresh decision") +} +scenario("replaced surface during recovery confirmation requires another decision") { + let store = fixture(); let key = store.surfaceViews.keys.first! + Persistence.requiresLoadRetry = true; Persistence.next = restoredSnapshot() + NSAlert.decisions = [{ store.surfaceViews[key] = GhosttySurfaceView(handle: 3, id: "replacement"); return .alertFirstButtonReturn }, { .alertSecondButtonReturn }] + store.offerSavedSessionRecovery() + check(Persistence.reads == 0 && NSAlert.prompts.count == 2 && store.surfaceViews[key]?.scrollbackID == "replacement", "new surface identity must be re-confirmed") +} +scenario("a newly busy temporary terminal requires another decision") { + let store = fixture(process: "zsh"); let key = store.surfaceViews.keys.first! + Persistence.requiresLoadRetry = true; Persistence.next = restoredSnapshot() + NSAlert.decisions = [{ store.surfaceViews[key]?.process = "vim"; return .alertFirstButtonReturn }, { .alertSecondButtonReturn }] + store.offerSavedSessionRecovery() + check(Persistence.reads == 0 && NSAlert.prompts.count == 2 && store.surfaceViews[key]?.process == "vim", "new busy terminals must not be killed by a prior decision") +} +scenario("saved data is reread after the recovery decision") { + let store = fixture(); Persistence.requiresLoadRetry = true; Persistence.next = restoredSnapshot("Before") + NSAlert.decisions = [{ Persistence.next = restoredSnapshot("Changed"); return .alertFirstButtonReturn }] + store.offerSavedSessionRecovery() + check(Persistence.reads == 1 && store.workspaces.first?.name == "Changed", "recovery must reread the current original") +} + +print("Executed \\(scenarios) lifecycle scenarios; \\(failures) assertion failures") +exit(failures == 0 ? 0 : 1) +`; +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'glint-lifecycle-')); +try { + const main = path.join(dir, 'main.swift'); + fs.writeFileSync(main, source); + const run = spawnSync('swift', ['-swift-version', '5', '-module-cache-path', path.join(os.tmpdir(), 'glint-lifecycle-cache'), main], + { encoding: 'utf8', timeout: 120000 }); + process.stdout.write(run.stdout ?? ''); + process.stderr.write(run.stderr ?? ''); + if (run.error) throw run.error; + process.exitCode = run.status ?? 1; +} finally { + fs.rmSync(dir, { recursive: true, force: true }); +} diff --git a/scripts/verify-dependency-lock.mjs b/scripts/verify-dependency-lock.mjs new file mode 100644 index 00000000..9fb5c100 --- /dev/null +++ b/scripts/verify-dependency-lock.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Keep the generated Xcode project, XcodeGen source and checked-in resolution +// aligned. Real resolution must fail rather than silently change this pin. +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +/** Read project text; relative is its repository path; returns UTF-8 contents. */ +const read = relative => fs.readFileSync(path.join(root, relative), 'utf8'); +const lock = JSON.parse(read('Glint.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved')); +assert.equal(lock.pins.length, 1); +const pin = lock.pins[0]; +assert.equal(pin.identity, 'sparkle'); +assert.equal(pin.kind, 'remoteSourceControl'); +assert.equal(pin.location, 'https://github.com/sparkle-project/Sparkle.git'); +assert.equal(pin.state.version, '2.6.4'); +assert.equal(pin.state.revision, '0ef1ee0220239b3776f433314515fd849025673f'); +assert.match(read('project.yml'), /Sparkle:\s+url: https:\/\/github.com\/sparkle-project\/Sparkle.git\s+exactVersion: "2\.6\.4"/); +assert.match(read('Glint.xcodeproj/project.pbxproj'), /kind = exactVersion;\s+version = 2\.6\.4;/); +for (const workflow of ['ci.yml', 'release.yml']) { + const content = read(`.github/workflows/${workflow}`); + for (const command of content.split(/\bxcodebuild \\\n/).slice(1)) { + const args = command.split(/\n\s*\n/)[0]; + assert.match(args, /-disableAutomaticPackageResolution/); + assert.match(args, /-onlyUsePackageVersionsFromResolvedFile/); + } +} +console.log('PASS: Sparkle exact declarations, real tag revision and CI/release lock enforcement agree');