Skip to content

Commit ff4df12

Browse files
committed
Measure OpenCode Go Zen wait from task start
1 parent 8f4efaf commit ff4df12

4 files changed

Lines changed: 98 additions & 16 deletions

File tree

Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy {
160160
}
161161
let workspaceOverride = context.settings?.opencodego?.workspaceID
162162
?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"]
163+
let zenBalanceStart = ContinuousClock.now
163164
let zenBalanceTask = Task<Double?, Error> {
164165
do {
165166
return try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance(
@@ -174,9 +175,9 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy {
174175
}
175176
let zenBalance = try await OpenCodeGoUsageFetcher.completedOptionalZenBalance(
176177
from: zenBalanceTask,
177-
timeout: OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(context: context)
178-
? .seconds(OpenCodeGoUsageFetcher.optionalZenBalanceTimeout)
179-
: OpenCodeGoUsageFetcher.optionalZenBalanceJoinGrace)
178+
timeout: OpenCodeGoUsageFetcher.optionalZenBalanceJoinTimeout(
179+
since: zenBalanceStart,
180+
waitForZenBalance: OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(context: context)))
180181
return (snapshot.withZenBalanceUSD(zenBalance), false)
181182
}
182183

Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ public struct OpenCodeGoUsageFetcher: Sendable {
149149
timeout: timeout,
150150
session: session)
151151
}
152+
let zenBalanceStart = ContinuousClock.now
152153
let zenBalanceTask = includeZenBalance ? Task {
153154
try await Task.sleep(for: self.optionalZenBalanceStartDelay)
154155
return try await self.fetchZenBalance(
@@ -197,9 +198,9 @@ public struct OpenCodeGoUsageFetcher: Sendable {
197198
}
198199
let zenBalance = try await self.completedOptionalZenBalance(
199200
from: zenBalanceTask,
200-
timeout: waitForZenBalance
201-
? .seconds(self.optionalZenBalanceTimeout)
202-
: Self.optionalZenBalanceJoinGrace)
201+
timeout: self.optionalZenBalanceJoinTimeout(
202+
since: zenBalanceStart,
203+
waitForZenBalance: waitForZenBalance))
203204
return snapshot.withZenBalanceUSD(zenBalance)
204205
}
205206

Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ extension OpenCodeGoUsageFetcher {
129129
}
130130
}
131131

132+
/// The optional balance join bound, measured from when the balance task was created so a slow
133+
/// subscription cannot stack a second full wait on top of the balance request. The app's short
134+
/// grace is unchanged; only completeness reads use the optional-balance timeout.
135+
static func optionalZenBalanceJoinTimeout(
136+
since startedAt: ContinuousClock.Instant,
137+
waitForZenBalance: Bool) -> Duration
138+
{
139+
guard waitForZenBalance else { return self.optionalZenBalanceJoinGrace }
140+
let remaining = .seconds(Self.optionalZenBalanceTimeout) - (ContinuousClock.now - startedAt)
141+
return max(Duration.zero, remaining)
142+
}
143+
132144
static func completedRequiredZenBalance(from task: Task<Double?, Error>) async throws -> Double? {
133145
let race = OpenCodeGoZenBalanceTaskRace(sourceTask: task)
134146
return try await race.value()

Tests/CodexBarTests/OpenCodeGoUsageFetcherCLIWaitTests.swift

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,43 @@ struct OpenCodeGoUsageFetcherCLIWaitTests {
125125
#expect(rootTimeout == 60)
126126
}
127127

128+
@Test
129+
func `cli wait policy bounds optional balance wait from task start`() async throws {
130+
defer {
131+
OpenCodeGoCLIWaitStubURLProtocol.handler = nil
132+
OpenCodeGoCLIWaitStubURLProtocol.hangPaths = []
133+
OpenCodeGoCLIWaitStubURLProtocol.delayedPaths = [:]
134+
}
135+
136+
OpenCodeGoCLIWaitStubURLProtocol.hangPaths = ["/workspace/wrk_TEST123"]
137+
OpenCodeGoCLIWaitStubURLProtocol.delayedPaths = ["/workspace/wrk_TEST123/go": 2]
138+
OpenCodeGoCLIWaitStubURLProtocol.handler = { request in
139+
guard let url = request.url else { throw URLError(.badURL) }
140+
return Self.makeResponse(
141+
url: url,
142+
body: Self.goUsagePageHTML(
143+
workspaceID: "wrk_TEST123",
144+
rolling: UsageWindow(percent: 17, resetInSec: 600),
145+
weekly: UsageWindow(percent: 75, resetInSec: 7200),
146+
monthly: nil),
147+
statusCode: 200,
148+
contentType: "text/html")
149+
}
150+
151+
let start = ContinuousClock.now
152+
let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage(
153+
cookieHeader: "auth=test",
154+
timeout: 60,
155+
workspaceIDOverride: "wrk_TEST123",
156+
waitForZenBalance: true,
157+
session: self.makeSession())
158+
let elapsed = start.duration(to: ContinuousClock.now)
159+
160+
#expect(snapshot.rollingUsagePercent == 17)
161+
#expect(snapshot.zenBalanceUSD == nil)
162+
#expect(elapsed < .seconds(6))
163+
}
164+
128165
private static func goUsagePageHTML(
129166
workspaceID: String,
130167
rolling: UsageWindow,
@@ -173,13 +210,25 @@ struct OpenCodeGoUsageFetcherCLIWaitTests {
173210
}
174211
}
175212

176-
private final class OpenCodeGoCLIWaitStubURLProtocol: URLProtocol {
213+
private final class OpenCodeGoCLIWaitStubURLProtocol: URLProtocol, @unchecked Sendable {
177214
private static let handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil)
178215
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? {
179216
get { Self.handlerBox.value }
180217
set { Self.handlerBox.setValue(newValue) }
181218
}
182219

220+
private static let hangPathsBox = LockIsolated<Set<String>>([])
221+
static var hangPaths: Set<String> {
222+
get { hangPathsBox.value }
223+
set { hangPathsBox.setValue(newValue) }
224+
}
225+
226+
private static let delayedPathsBox = LockIsolated<[String: TimeInterval]>([:])
227+
static var delayedPaths: [String: TimeInterval] {
228+
get { delayedPathsBox.value }
229+
set { delayedPathsBox.setValue(newValue) }
230+
}
231+
183232
override static func canInit(with request: URLRequest) -> Bool {
184233
request.url?.host == "opencode.ai"
185234
}
@@ -189,18 +238,37 @@ private final class OpenCodeGoCLIWaitStubURLProtocol: URLProtocol {
189238
}
190239

191240
override func startLoading() {
192-
guard let handler = Self.handler else {
193-
self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
241+
guard let url = self.request.url else {
242+
self.client?.urlProtocol(self, didFailWithError: URLError(.badURL))
243+
return
244+
}
245+
if Self.hangPaths.contains(url.path) {
194246
return
195247
}
196-
do {
197-
let (response, data) = try handler(self.request)
198-
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
199-
self.client?.urlProtocol(self, didLoad: data)
200-
self.client?.urlProtocolDidFinishLoading(self)
201-
} catch {
202-
self.client?.urlProtocol(self, didFailWithError: error)
248+
let delay = Self.delayedPaths[url.path] ?? 0
249+
let deliver: () -> Void = { [weak self] in
250+
guard let self else { return }
251+
do {
252+
let (response, data) = try Self.response(for: self.request)
253+
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
254+
self.client?.urlProtocol(self, didLoad: data)
255+
self.client?.urlProtocolDidFinishLoading(self)
256+
} catch {
257+
self.client?.urlProtocol(self, didFailWithError: error)
258+
}
259+
}
260+
if delay > 0 {
261+
DispatchQueue.global().asyncAfter(deadline: .now() + delay, execute: deliver)
262+
} else {
263+
deliver()
264+
}
265+
}
266+
267+
private static func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
268+
guard let handler = Self.handler else {
269+
throw URLError(.badServerResponse)
203270
}
271+
return try handler(request)
204272
}
205273

206274
override func stopLoading() {}

0 commit comments

Comments
 (0)