From 47c9bfcdbb6d30833d195490dfe7494aa7d7bbc9 Mon Sep 17 00:00:00 2001 From: Jun <1059013684@qq.com> Date: Mon, 19 Oct 2026 16:35:16 +0800 Subject: [PATCH 1/2] fix: harden session recovery and remote resource lifecycles --- .github/workflows/ci.yml | 17 +- .github/workflows/release.yml | 5 + .gitignore | 1 + Glint.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/swiftpm/Package.resolved | 14 + Glint/Agent/ControlBridge.swift | 91 +++-- Glint/Agent/UsageStore.swift | 157 ++++---- Glint/Git/GitDiff.swift | 106 ++++-- Glint/Git/GitService.swift | 20 +- Glint/Pane/GhosttySurfaceView.swift | 5 +- Glint/Resources/Localizable.xcstrings | 55 +++ Glint/Review/ReviewWindow.swift | 53 ++- Glint/WebRemote/WebRemoteServer.swift | 193 +++++++--- Glint/Workspace/Persistence.swift | 76 ++-- Glint/Workspace/WorkspaceStore.swift | 51 ++- GlintTests/PersistenceRecoveryTests.swift | 103 +++++- GlintTests/ReviewDiffParsingTests.swift | 49 ++- .../WebRemoteServerIntegrationTests.swift | 92 ++++- project.yml | 2 +- scripts/test-control-lifecycle.mjs | 138 +++++++ scripts/test-git-review.mjs | 168 ++++++++- scripts/test-git-untracked.mjs | 174 +++++++++ scripts/test-http-lifecycle.mjs | 261 ++++++++++++++ scripts/test-usage-refresh.mjs | 341 ++++++++++++++++++ scripts/test-web-file-admission.mjs | 312 ++++++++++++++++ scripts/test-web-remote-queue.mjs | 91 +++++ scripts/test-workspace-lifecycle.mjs | 290 +++++++++++++++ scripts/verify-dependency-lock.mjs | 29 ++ 28 files changed, 2658 insertions(+), 240 deletions(-) create mode 100644 Glint.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 scripts/test-control-lifecycle.mjs create mode 100644 scripts/test-git-untracked.mjs create mode 100644 scripts/test-http-lifecycle.mjs create mode 100644 scripts/test-usage-refresh.mjs create mode 100644 scripts/test-web-file-admission.mjs create mode 100644 scripts/test-web-remote-queue.mjs create mode 100644 scripts/test-workspace-lifecycle.mjs create mode 100644 scripts/verify-dependency-lock.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e2a68df..57945bd5 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 \ @@ -60,11 +65,15 @@ jobs: scripts/verify-ghostty-resources.sh build/Build/Products/Debug/Glint.app - - 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: | @@ -74,6 +83,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 f7f5ba14..cfb68e70 100644 --- a/Glint.xcodeproj/project.pbxproj +++ b/Glint.xcodeproj/project.pbxproj @@ -995,8 +995,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 f2e46a3e..b93dd0dd 100644 --- a/Glint/Agent/ControlBridge.swift +++ b/Glint/Agent/ControlBridge.swift @@ -25,13 +25,18 @@ final class ControlBridge { private(set) var socketPath: String = "" private(set) var tokenPath: String = "" private var token: String = "" + private let lifecycleLock = NSLock() + private var generation: UUID? + 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 use the same lock. + var isRunning: Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + return generation != nil + } private init() {} @@ -53,6 +58,7 @@ final class ControlBridge { /// Bind + listen on `~/.glint/run/control.sock` (Debug: `control-debug.sock`). /// Same 0700-parent / 0600-socket discipline as `AgentBridge` — see its /// `start()` for the rationale on why a private parent dir matters here. + /// Takes no parameters; returns nothing and stays stopped on bind failure. func start() { guard !isRunning else { return } let home = FileManager.default.homeDirectoryForCurrentUser @@ -115,25 +121,35 @@ final class ControlBridge { return } + // A cancelled source can have a queued handler. Nonblocking accept + // plus the lifecycle lock prevents that handler accepting a reused FD. + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + lifecycleLock.lock() listenFD = fd + generation = UUID() + lifecycleLock.unlock() let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) src.setEventHandler { [weak self] in self?.acceptOne() } + 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. Connections already accepted finish on their - /// own; no new ones are accepted. + /// No parameters; returns nothing. Accepted connections are shut down; + /// their serving tasks retain ownership of close() to prevent FD reuse. func stop() { - guard isRunning else { return } - isRunning = false + lifecycleLock.lock() + guard generation != nil else { lifecycleLock.unlock(); return } + generation = nil + token = "" + listenFD = -1 + for fd in acceptedClients { _ = shutdown(fd, SHUT_RDWR) } + lifecycleLock.unlock() acceptSource?.cancel() acceptSource = nil - if listenFD >= 0 { close(listenFD); listenFD = -1 } 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. @@ -171,9 +187,10 @@ final class ControlBridge { chmod(path, 0o600) } - /// Constant-time compare — no early return on first mismatch. + /// Compare a supplied token on main immediately before execution; returns + /// whether it matches the nonempty live token without early byte exits. private func tokenMatches(_ provided: String?) -> Bool { - guard let provided else { return false } + guard let provided, !token.isEmpty else { return false } let a = Array(provided.utf8) let b = Array(token.utf8) var diff = UInt8(a.count == b.count ? 0 : 1) @@ -188,19 +205,35 @@ final class ControlBridge { // MARK: accept / serve + /// Accept a client for the current generation; no inputs or return value. private func acceptOne() { + lifecycleLock.lock() + guard let run = generation, listenFD >= 0 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 tv = timeval(tv_sec: 5, tv_usec: 0) _ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) - DispatchQueue.global(qos: .utility).async { [weak self] in self?.serve(fd: client) } + _ = setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) + var noPipe: Int32 = 1 + _ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noPipe, socklen_t(MemoryLayout.size)) + DispatchQueue.global(qos: .utility).async { self.serve(fd: client, generation: run) } } /// 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. - private func serve(fd: Int32) { - defer { close(fd) } + /// Parameters: owned client descriptor and accepting generation. Returns + /// nothing; closes its descriptor after EOF, shutdown, timeout or failure. + private func serve(fd: Int32, generation: UUID) { + defer { + lifecycleLock.lock() + acceptedClients.remove(fd) + close(fd) + lifecycleLock.unlock() + } var buf = Data() var tmp = [UInt8](repeating: 0, count: 4096) while true { @@ -212,7 +245,7 @@ final class ControlBridge { while let nl = buf.firstIndex(of: 0x0A) { let line = buf.subdata(in: buf.startIndex.. Data { + /// Handle a request from one accepting generation; returns a JSON response. + /// The main-thread gate orders revocation before all queued store actions. + private func handle(line: Data, generation: 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") } - // Command-level gating: send-* mutate the terminal, so they require a - // token; focus/list don't (see §4.4). Auth is checked off the main - // thread — it's a pure comparison. - let requiresToken = (cmd == "send-text" || cmd == "send-key") - if requiresToken && !tokenMatches(obj["token"] as? String) { - return Self.errorResponse("unauthorized") - } - var response = Self.errorResponse("unknown-cmd") DispatchQueue.main.sync { + self.lifecycleLock.lock() + let active = self.generation == generation + self.lifecycleLock.unlock() + guard active else { + response = Self.errorResponse("unauthorized") + return + } + let requiresToken = (cmd == "send-text" || cmd == "send-key") + guard !requiresToken || self.tokenMatches(obj["token"] as? String) else { + response = Self.errorResponse("unauthorized") + return + } guard let store = WorkspaceStore.current else { response = Self.errorResponse("bad-request") return diff --git a/Glint/Agent/UsageStore.swift b/Glint/Agent/UsageStore.swift index 90e34962..01dabc44 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() } + 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,13 +207,15 @@ 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)] = [:] + private var codexHomeRefreshes: [URL: (id: UUID, task: Task)] = [:] /// 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 } + /// Load enabled agents and cached quotas; takes no arguments and initializes the poller. init() { self.claudeEnabled = (UserDefaults.standard.object(forKey: Self.claudeKey) as? Bool) ?? false self.codexEnabled = (UserDefaults.standard.object(forKey: Self.codexKey) as? Bool) ?? false @@ -215,15 +226,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() } + codexHomeRefreshes.values.forEach { $0.task.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 +251,87 @@ 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, read: 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[agent] = nil + self.apply(quota, to: agent) + } + agentRefreshes[agent] = (id, task) + } + + /// Cancel an agent's pending read and invalidate its identity; input is agent; returns nothing. + private func cancelRefresh(for agent: Agent) { + agentRefreshes.removeValue(forKey: agent)?.task.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() + 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[url] = nil + 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) } + codexHomeRefreshes[url] = (id, task) } } + /// 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 +446,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..1bec04a7 100644 --- a/Glint/Git/GitDiff.swift +++ b/Glint/Git/GitDiff.swift @@ -31,45 +31,37 @@ 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 only when Git succeeds without changes. Throws on Git/transport failure. + func changedFiles(repo: String, scope: DiffScope) async throws -> [GitFileChange] { 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) - 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) - } + nameStatus: try await names.stdout, + numstat: try await nums.stdout) + 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) } return withMtime(map.values.sorted { $0.path < $1.path }, repo: repo) 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 ?? "") + nameStatus: try await names.stdout, + numstat: try await nums.stdout) return withMtime(map.values.sorted { $0.path < $1.path }, repo: repo) } } @@ -89,7 +81,9 @@ 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 and whitespace preference. + /// 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 +91,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) 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 +108,50 @@ 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 = 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) } - guard let r = result, !r.wasSignaled else { return "" } - return r.stdout + 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 HEAD or the 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" } + 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) + } + // 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 6463e6bf..185cc3d7 100644 --- a/Glint/Pane/GhosttySurfaceView.swift +++ b/Glint/Pane/GhosttySurfaceView.swift @@ -570,7 +570,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/ @@ -581,7 +582,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 6212cc88..a9299b3e 100644 --- a/Glint/Resources/Localizable.xcstrings +++ b/Glint/Resources/Localizable.xcstrings @@ -36,6 +36,17 @@ } } }, + "%d of its panes still have something running; archiving will terminate their processes.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其中 %d 个面板仍有任务在运行,归档将终止这些进程。" + } + } + } + }, "%d of its panes still have something running; everything in this workspace will be terminated.": { "extractionState": "stale", "localizations": { @@ -476,6 +487,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": { @@ -6031,6 +6064,28 @@ } } } + }, + "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": "无法加载差异,请刷新重试。" + } + } + } } }, "version": "1.0" diff --git a/Glint/Review/ReviewWindow.swift b/Glint/Review/ReviewWindow.swift index 9964687f..7d586d16 100644 --- a/Glint/Review/ReviewWindow.swift +++ b/Glint/Review/ReviewWindow.swift @@ -44,6 +44,8 @@ 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 diffError: String? private let git: GitService private var fileLoadToken = 0 // guards against out-of-order file-list loads @@ -61,13 +63,33 @@ 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 loadingFiles = true - var fs = await git.changedFiles(repo: repo, scope: scope) + filesError = nil + diffError = nil + diffLoadToken += 1 + loadingDiff = false + let loaded: [GitFileChange] + do { + loaded = try await git.changedFiles(repo: repo, scope: scope) + } catch { + guard token == fileLoadToken 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 guard token == fileLoadToken else { return } // Scope the file list to the focused pane's subtree when reviewing a // subdirectory. Git reports root-relative paths, so a prefix filter @@ -109,6 +131,8 @@ 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 { selected = f diffLoadToken += 1 @@ -116,7 +140,17 @@ final class ReviewModel: ObservableObject { 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: scope, file: f, ignoreWhitespace: ignoreWhitespace) + } catch { + guard token == diffLoadToken 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 } // Parse off the main actor so a multi-thousand-line diff doesn't hitch @@ -131,12 +165,14 @@ final class ReviewModel: ObservableObject { 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 +334,13 @@ 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 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 { Text("No changes") .font(.system(size: 12)) .foregroundStyle(Theme.text3) @@ -450,6 +492,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,7 +915,9 @@ 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.loadingDiff && model.diffText.isEmpty { hint(String(localized: "Loading…")) } else if model.selected == nil { hint(String(localized: "No changes to review")) diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift index c048fd1f..1d71afff 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -83,20 +83,37 @@ struct WebRemoteOutboundBuffer { } let maxQueuedOutputBytes: Int + let maxQueuedBytes: Int + let maxQueuedItems: Int private var items: [Item] = [] private var queuedOutputBytes = 0 + private var queuedBytes = 0 - init(maxQueuedOutputBytes: Int) { + /// 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 } - mutating func enqueueMessage(_ data: Data) { + /// Queue a JSON response; data is its encoded body; returns false at capacity. + @discardableResult + mutating func enqueueMessage(_ data: Data) -> Bool { + guard data.count <= maxQueuedBytes - queuedBytes, + items.count < maxQueuedItems else { return false } items.append(.message(data)) + queuedBytes += data.count + return true } + /// 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 } + guard data.count <= maxQueuedOutputBytes - queuedOutputBytes, + data.count <= maxQueuedBytes - queuedBytes else { return false } if let last = items.last, case let .terminalOutput(existingPane, existingData) = last, existingPane == pane { @@ -107,24 +124,33 @@ 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? { guard !items.isEmpty else { return nil } let item = items.removeFirst() - if case let .terminalOutput(_, data) = item { + switch item { + case let .terminalOutput(_, data): queuedOutputBytes -= data.count + queuedBytes -= data.count + case let .message(data): + queuedBytes -= data.count } return item } + /// Clear queued responses and all accounting; no parameters or return value. mutating func removeAll() { items.removeAll() queuedOutputBytes = 0 + queuedBytes = 0 } } @@ -168,6 +194,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 +255,9 @@ 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 activeFileRequests: [UUID: UUID] = [:] + private let httpRequestTimeout: TimeInterval private var authThrottle = WebRemoteAuthThrottle( countCap: WebRemoteServer.authFailureCountCap, expirySeconds: WebRemoteServer.authFailureExpirySeconds @@ -255,10 +285,12 @@ final class WebRemoteServer: @unchecked Sendable { init( defaults: UserDefaults = .standard, interfaceRetryInterval: TimeInterval = 5, + httpRequestTimeout: TimeInterval = 10, resolveBindAddress: @escaping (String) -> String? = WebRemoteListenTarget.bindAddress ) { self.defaults = defaults self.interfaceRetryInterval = interfaceRetryInterval + self.httpRequestTimeout = httpRequestTimeout self.resolveBindAddress = resolveBindAddress ports = WebRemotePortStore.loadOrCreate(defaults: defaults) } @@ -449,7 +481,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 +640,11 @@ 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() authThrottle.removeAll() readyListeners.removeAll() token = "" @@ -642,28 +680,51 @@ final class WebRemoteServer: @unchecked Sendable { } } - private func handleHTTPConnection(_ connection: NWConnection) { + /// Track a connection for this listener generation; returns nothing. The + /// fixed deadline includes header receipt and response completion, so slow + /// readers cannot keep a slot indefinitely after submitting their headers. + 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 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 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)) + 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 @@ -731,9 +792,11 @@ final class WebRemoteServer: @unchecked Sendable { sendHTTP(response, to: connection) } + /// Send a response body and release its tracked connection; returns nothing. private func sendHTTP(_ data: Data, to connection: NWConnection) { - connection.send(content: data, completion: .contentProcessed { _ in - connection.cancel() + connection.send(content: data, completion: .contentProcessed { [weak self, weak connection] _ in + guard let self, let connection else { return } + self.finishHTTPConnection(connection) }) } @@ -1097,57 +1160,84 @@ final class WebRemoteServer: @unchecked Sendable { } } + /// Read/list files for a live client; inputs identify its request. Returns + /// nothing. Keep tickets until I/O finishes, even across stop/restart, so + /// toggling the server cannot bypass the global background-work budget. private func accessFiles(_ request: WebRemoteFileRequest, clientID: UUID) { + guard clients[clientID] != nil else { return } + guard activeFileRequests.count < 8, + activeFileRequests.values.filter({ $0 == clientID }).count < 2 else { + dropClientLocked(clientID, reason: "file request limit reached") + return + } + let ticket = UUID() + activeFileRequests[ticket] = clientID DispatchQueue.main.async { [weak self] in - guard let self, let store = WorkspaceStore.current else { return } + guard let self else { return } + guard let store = WorkspaceStore.current else { + self.queue.async { self.activeFileRequests.removeValue(forKey: ticket) } + return + } let currentRoot = store.webRemoteFileRoot(workspace: request.workspace, pane: request.pane) guard case let .success(root) = request.validatedRoot(current: currentRoot) else { let error: WebRemoteFiles.ErrorCode = currentRoot == nil ? .rootUnavailable : .rootChanged - self.queue.async { [weak self] in self?.sendJSON(request.error(error), to: clientID) } + self.queue.async { + self.activeFileRequests.removeValue(forKey: ticket) + self.sendJSON(request.error(error), to: clientID) + } return } - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - var response: [String: Any] = [ - "workspace": request.workspace.uuidString, "pane": request.pane, - "path": request.path, "root": root, "request": request.requestID, - ] - if request.read { - if WebRemoteFiles.isImage(request.path) { - switch WebRemoteFiles.readImage(root: root, path: request.path) { - case let .success(png): - response["type"] = "fileImage" - response["data"] = png.base64EncodedString() - case let .failure(error): response = request.error(error) + self.queue.async { [self] in + guard self.clients[clientID] != nil else { + self.activeFileRequests.removeValue(forKey: ticket) + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + var response: [String: Any] = [ + "workspace": request.workspace.uuidString, "pane": request.pane, + "path": request.path, "root": root, "request": request.requestID, + ] + if request.read { + if WebRemoteFiles.isImage(request.path) { + switch WebRemoteFiles.readImage(root: root, path: request.path) { + case let .success(png): + response["type"] = "fileImage" + response["data"] = png.base64EncodedString() + case let .failure(error): response = request.error(error) + } + } else { + switch WebRemoteFiles.read(root: root, path: request.path) { + case let .success(content): + response["type"] = "fileContent" + response["content"] = content + response["format"] = WebRemoteFiles.isHTML(request.path) ? "html" : "text" + case let .failure(error): response = request.error(error) + } } } else { - switch WebRemoteFiles.read(root: root, path: request.path) { - case let .success(content): - response["type"] = "fileContent" - response["content"] = content - response["format"] = WebRemoteFiles.isHTML(request.path) ? "html" : "text" + switch WebRemoteFiles.list(root: root, path: request.path) { + case let .success(entries): + response["type"] = "fileList" + response["entries"] = entries + response["limit"] = WebRemoteFiles.maxEntries case let .failure(error): response = request.error(error) } } - } else { - switch WebRemoteFiles.list(root: root, path: request.path) { - case let .success(entries): - response["type"] = "fileList" - response["entries"] = entries - response["limit"] = WebRemoteFiles.maxEntries - case let .failure(error): response = request.error(error) + // I/O can outlive a cd, pane removal, or transition to SSH. + // Revalidate before handing the result back to the browser. + let completedResponse = response + DispatchQueue.main.async { [weak self] in + let latest = WorkspaceStore.current?.webRemoteFileRoot( + workspace: request.workspace, pane: request.pane + ) + let checkedResponse = latest == root ? completedResponse + : request.error(latest == nil ? .rootUnavailable : .rootChanged) + self?.queue.async { [weak self] in + self?.activeFileRequests.removeValue(forKey: ticket) + self?.sendJSON(checkedResponse, to: clientID) + } } } - // I/O can outlive a cd, pane removal, or transition to SSH. - // Revalidate before handing the result back to the browser. - let completedResponse = response - DispatchQueue.main.async { [weak self] in - let latest = WorkspaceStore.current?.webRemoteFileRoot( - workspace: request.workspace, pane: request.pane - ) - let checkedResponse = latest == root ? completedResponse - : request.error(latest == nil ? .rootUnavailable : .rootChanged) - self?.queue.async { [weak self] in self?.sendJSON(checkedResponse, to: clientID) } - } } } } @@ -1401,7 +1491,10 @@ private final class WebRemoteClientConnection: @unchecked Sendable { func send(_ data: Data) { guard !cancelled else { return } - outbound.enqueueMessage(data) + guard outbound.enqueueMessage(data) else { + cancel() + return + } sendNextIfNeeded() } diff --git a/Glint/Workspace/Persistence.swift b/Glint/Workspace/Persistence.swift index 042bcc13..83ea41e2 100644 --- a/Glint/Workspace/Persistence.swift +++ b/Glint/Workspace/Persistence.swift @@ -58,9 +58,9 @@ 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. + /// Path of an unreadable/corrupt state.json we could not preserve. A later + /// save retries preservation before writing, so recovered permissions or + /// disk space allow saving again without sacrificing the original. private static var corruptUnmovablePath: String? /// Returns nil both for "no saved state" (fresh install) and "state was @@ -68,10 +68,28 @@ enum Persistence { /// 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. + /// costs only that pane, not every workspace. Takes no parameters; returns + /// the restored snapshot or nil when a fresh in-memory state is needed. 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 { + let failure = error as NSError + if failure.domain != NSCocoaErrorDomain || failure.code != NSFileReadNoSuchFileError { + NSLog("[glint] couldn't read \(fileName): \(error); preserving the original before starting fresh") + } + // A missing target can still leave a real symlink at state.json; + // only the move's missing-source result proves nothing needs saving. + _ = preserveOriginal(at: url) + return nil + } do { return try JSONDecoder().decode(PersistedState.self, from: data) } catch { @@ -109,29 +127,43 @@ 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") + _ = preserveOriginal(at: url) return 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 } - 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 - } + 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) { + if corruptUnmovablePath == url.path, !preserveOriginal(at: url) { return } let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .sortedKeys] do { diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index f1506dd1..422eb805 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -1781,7 +1781,7 @@ final class WorkspaceStore: ObservableObject { guard let self else { return } self.clearDockBadge() self.captureCwdsFromLiveSurfaces() - self.flushScrollback() + self.flushScrollback(force: true) self.persist() } ScrollbackArchive.drain() @@ -2036,13 +2036,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 @@ -2051,7 +2047,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) } } @@ -3453,7 +3449,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 } @@ -3464,20 +3461,50 @@ 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: String(format: String(localized: "%d of its panes still have something running; archiving will terminate their processes."), 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/PersistenceRecoveryTests.swift b/GlintTests/PersistenceRecoveryTests.swift index d4d39fc4..87d7e680 100644 --- a/GlintTests/PersistenceRecoveryTests.swift +++ b/GlintTests/PersistenceRecoveryTests.swift @@ -3,12 +3,111 @@ 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) + } + } + + /// Preserve an unreadable original before fresh state is saved; no inputs; returns no value and asserts both snapshots survive. + func testUnreadableStateIsPreservedBeforeSave() 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)) + let replacement = PersistedState.fresh + Persistence.save(replacement, to: url) + XCTAssertTrue(try preservedStateBytes(at: url).contains(original)) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + XCTAssertNotNil(try JSONDecoder().decode(PersistedState.self, from: Data(contentsOf: url))) + } + } + + /// Retry a failed preservation when directory access recovers; no inputs; returns no value and asserts originals remain protected. + func testUnreadableBackupFailureCanRecoverOnLaterSave() 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) + let parent = url.deletingLastPathComponent() + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: parent.path) + XCTAssertNil(Persistence.load(from: url)) + Persistence.save(.fresh, to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: parent.path) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + XCTAssertEqual(try Data(contentsOf: url), original) + Persistence.save(.fresh, to: url) + XCTAssertTrue(try preservedStateBytes(at: url).contains(original)) + XCTAssertNotNil(Persistence.load(from: url)) + } + } + + /// 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)) + } + } + + /// 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) + } + } + + /// Preserve an existing link whose target is temporarily missing; no inputs; returns no value and asserts the link survives in backup. + func testDanglingStateSymlinkIsPreserved() 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 backups = try FileManager.default.contentsOfDirectory(at: url.deletingLastPathComponent(), includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.hasPrefix("state.json.corrupt-") } + XCTAssertEqual(backups.count, 1) + if let backup = backups.first { + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: backup.path), target.path) + } + XCTAssertNotNil(Persistence.load(from: url)) + } + } + /// 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..657f6aa2 100644 --- a/GlintTests/ReviewDiffParsingTests.swift +++ b/GlintTests/ReviewDiffParsingTests.swift @@ -29,6 +29,53 @@ 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 partial file lists. Parameters: none. Returns nothing; asserts errors from every list step. + func testChangedFilesThrowsForFailedGitRead() async { + for step in ["--name-status", "--numstat", "ls-files"] { + do { + _ = try await GitService(runner: FailedDiffRunner(failAt: step)) + .changedFiles(repo: "/unused", scope: .workingTree) + XCTFail("Expected Git failure for \(step)") + } catch { } + } + } + + /// 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 +316,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 d88a6d48..7a66eb91 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 318191e6..b0aa91a2 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..04473feb --- /dev/null +++ b/scripts/test-control-lifecycle.mjs @@ -0,0 +1,138 @@ +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 + /// 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 } +} +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 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(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..90cf8127 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,71 @@ 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") ? "abcdef\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] { [:] } +} +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 +141,52 @@ 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] == "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 +200,54 @@ struct Row: Encodable { let path: String; let kind: String; let additions: Int; assert.equal(rows.find(row => row.path === 'binary.dat').binary, 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', 'ls-files', '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 +282,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..1a70bfe6 --- /dev/null +++ b/scripts/test-http-lifecycle.mjs @@ -0,0 +1,261 @@ +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 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) { + sendHTTP(Data("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK".utf8), 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") +} +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), +] +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")).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..c81c2bca --- /dev/null +++ b/scripts/test-usage-refresh.mjs @@ -0,0 +1,341 @@ +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"); + +// 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. +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 } +} +enum CodexUsageReader { + /// Suspend a test quota request; input is its home; returns the injected quota. + static func readPreferLive(from home: URL) async -> AgentQuota? { + await Probe.shared.read(home.path) + } +} +enum ClaudeUsageReader { + /// Suspend a test quota request; takes no inputs; returns the injected quota. + static func read() async -> AgentQuota? { await Probe.shared.read("claude") } +} +enum GrokUsageReader { + /// Suspend a test quota request; takes no inputs; returns the injected quota. + static func read() async -> AgentQuota? { await Probe.shared.read("grok") } +} +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] = [] + /// 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() + 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 = [] + } + /// 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") + } + /// 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), + ("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..939d0b9b --- /dev/null +++ b/scripts/test-web-file-admission.mjs @@ -0,0 +1,312 @@ +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 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 { + enum ErrorCode: String, Error { case rootUnavailable, rootChanged, unavailable } + 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. + func error(_ code: WebRemoteFiles.ErrorCode) -> [String: Any] { + ["type": "fileError", "code": code.rawValue, "request": requestID] + } +} +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) + await settle() + try expect(fixture.snapshot().drops.contains(client), "third request did not drop its client") + 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.isEmpty, "dropped client received a late response") + } + /// 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) + await settle() + try expect(fixture.snapshot().drops.contains(rejected), "new client bypassed the global I/O cap") + 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 == 1 && fixture.snapshot().replies.first?.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 == "rootUnavailable", "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 == "rootChanged", "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 == "rootChanged", "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))); + 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-queue.mjs b/scripts/test-web-remote-queue.mjs new file mode 100644 index 00000000..341dec27 --- /dev/null +++ b/scripts/test-web-remote-queue.mjs @@ -0,0 +1,91 @@ +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 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' + 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: 8, maxQueuedBytes: 10, maxQueuedItems: 2) +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: 10)), "reset releases full shared budget") +let slow = SlowClient() +for _ in 0..<3 { slow.send(image) } +check(slow.cancelled && slow.connection.cancellations == 1, "slow sender cancels on ordinary queue overflow") +check(slow.outbound.next() == nil, "overflow cancellation releases all queued data") +slow.send(Data([1])) +check(slow.outbound.next() == nil, "cancelled connection ignores later responses") +`; +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..5d4a1702 --- /dev/null +++ b/scripts/test-workspace-lifecycle.mjs @@ -0,0 +1,290 @@ +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 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] = [:] + /// 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) + } + /// 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 Status { case thinking, tool, compacting, needsPermission, idle } +struct AgentState { let status: Status } +final class Store { + var workspaces: [Workspace] = [] + var selectedWorkspaceID: UUID? + 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 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 { + 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 } + /// 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 + 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("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") +} +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'); From ff3e3491e5c3d28175641ad82bfc688a57242010 Mon Sep 17 00:00:00 2001 From: Jun <1059013684@qq.com> Date: Mon, 19 Oct 2026 16:43:17 +0800 Subject: [PATCH 2/2] fix: preserve Grok reader signature in quota refresh tests --- Glint/Agent/UsageStore.swift | 2 +- scripts/test-usage-refresh.mjs | 35 ++++++++++++++++++++-------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/Glint/Agent/UsageStore.swift b/Glint/Agent/UsageStore.swift index 01dabc44..833ea8d8 100644 --- a/Glint/Agent/UsageStore.swift +++ b/Glint/Agent/UsageStore.swift @@ -258,7 +258,7 @@ final class UsageStore: ObservableObject { refreshAgent(.claude, read: ClaudeUsageReader.read) } if grokEnabled { - refreshAgent(.grok, read: GrokUsageReader.read) + refreshAgent(.grok) { await GrokUsageReader.read() } } } diff --git a/scripts/test-usage-refresh.mjs b/scripts/test-usage-refresh.mjs index c81c2bca..36c8ce7d 100644 --- a/scripts/test-usage-refresh.mjs +++ b/scripts/test-usage-refresh.mjs @@ -14,9 +14,24 @@ const homesSource = fs.readFileSync(path.join(root, "Glint/Agent/CodexHome.swift 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 { @@ -51,20 +66,12 @@ enum CodexLiveReader { /// Avoid credential access; input is a home URL; returns a placeholder. static func authStatus(from home: URL) -> CodexAuthStatus { .found } } -enum CodexUsageReader { - /// Suspend a test quota request; input is its home; returns the injected quota. - static func readPreferLive(from home: URL) async -> AgentQuota? { - await Probe.shared.read(home.path) - } -} -enum ClaudeUsageReader { - /// Suspend a test quota request; takes no inputs; returns the injected quota. - static func read() async -> AgentQuota? { await Probe.shared.read("claude") } -} -enum GrokUsageReader { - /// Suspend a test quota request; takes no inputs; returns the injected quota. - static func read() async -> AgentQuota? { await Probe.shared.read("grok") } -} +${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] = [:]