Skip to content

Commit e8d4365

Browse files
committed
fix: fence quota wrapper disposal and prove lock post-rename verification
1 parent 97e5957 commit e8d4365

5 files changed

Lines changed: 198 additions & 32 deletions

File tree

packages/core/src/file-lock.test.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,13 @@ describe('acquireFencedFileLock — renewal TOCTOU', () => {
951951
const target = join(root, 'state.json')
952952
const lockPath = `${target}.accounts.lock`
953953
const clearIntervalSpy = spyOn(globalThis, 'clearInterval')
954+
let renewalTick: (() => void) | null = null
955+
const setIntervalSpy = spyOn(globalThis, 'setInterval').mockImplementation(
956+
((callback: () => void) => {
957+
renewalTick = callback
958+
return 0 as unknown as ReturnType<typeof setInterval>
959+
}) as typeof setInterval,
960+
)
954961

955962
let pausedResolve!: () => void
956963
const paused = new Promise<void>((resolve) => {
@@ -967,7 +974,7 @@ describe('acquireFencedFileLock — renewal TOCTOU', () => {
967974
path: target,
968975
name: 'accounts',
969976
ttlMs: 60_000,
970-
renewIntervalMs: 10,
977+
renewIntervalMs: 60_000,
971978
onStep: async (step) => {
972979
if (step === 'renew-committed' && !hookFired) {
973980
hookFired = true
@@ -977,15 +984,25 @@ describe('acquireFencedFileLock — renewal TOCTOU', () => {
977984
},
978985
})
979986
expect(lock).not.toBeNull()
987+
expect(renewalTick).not.toBeNull()
980988

981-
// A's renewal has just renamed its refreshed payload onto the
982-
// lock file; owner B's acquisition lands in the post-commit
983-
// window, before A's verify re-read.
989+
// Drive exactly one renewal. With no later timer callback available,
990+
// only the immediate post-rename re-read can observe B's takeover.
991+
renewalTick!()
984992
await paused
985993
await writeLock(lockPath, 'owner-B', Date.now() + 60_000)
986994
gateResolve()
987995

988-
await lock!.whenLost()
996+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
997+
const loss = await Promise.race([
998+
lock!.whenLost(),
999+
new Promise<'timeout'>((resolve) => {
1000+
timeoutHandle = setTimeout(() => resolve('timeout'), 100)
1001+
}),
1002+
]).finally(() => {
1003+
if (timeoutHandle !== null) clearTimeout(timeoutHandle)
1004+
})
1005+
expect(loss).toBeUndefined()
9891006
expect(lock!.hasLost()).toBe(true)
9901007
expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
9911008

@@ -1000,6 +1017,7 @@ describe('acquireFencedFileLock — renewal TOCTOU', () => {
10001017
await rm(lockPath, { force: true })
10011018
} finally {
10021019
clearIntervalSpy.mockRestore()
1020+
setIntervalSpy.mockRestore()
10031021
}
10041022
})
10051023

packages/core/src/file-lock.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -396,13 +396,10 @@ function buildLock(
396396
}
397397
if (observed.expiresAt > now()) {
398398
if (released || lost) return
399-
// TOCTOU protection: the lock file may have been swapped out
400-
// from under us between the read above and the write below.
401-
// Compare-and-swap via a sibling temp file + atomic rename so
402-
// we never overwrite a fresh owner's lock content directly.
403-
// The remaining race window — between the re-read and the
404-
// rename — collapses to a single inode replace that POSIX
405-
// rename(2) makes atomic at the filesystem level.
399+
// Rename cannot compare-and-swap ownership. Re-read after the
400+
// rename and mark this lock lost if another owner replaced it;
401+
// writers call assertOwned() before mutating shared state, so a
402+
// hijack in this seam is detected and stops future renewals.
406403
const tempPath = `${lockPath}.${ownerId}.tmp`
407404
let shouldCommit = false
408405
try {

packages/opencode/src/plugin/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ import {
3232
import { createEventHandler } from './event-handler'
3333
import { createFetchInterceptor } from './fetch-interceptor'
3434
import { createGoogleSearchTool } from './google-search-tool'
35-
import { createPluginLifecycle } from './lifecycle'
35+
import { createPluginLifecycle, type PluginLifecycle } from './lifecycle'
3636
import { createLogger, initLogger, setRuntimeLogLevel } from './logger'
3737
import { createOAuthMethods, openBrowserWithSystem } from './oauth-methods'
3838
import {
3939
createOperatorSettingsController,
4040
type OperatorSettingsController,
4141
} from './operator-settings'
4242
import { persistAccountPool } from './persist-account-pool'
43-
import { createOpenCodeQuotaManager } from './quota'
43+
import { createOpenCodeQuotaManager, type QuotaManager } from './quota'
4444
import { createSessionRecoveryHook } from './recovery'
4545
import { initHealthTracker, initTokenTracker } from './rotation'
4646
import { AgySessionRegistry } from './session-context'
@@ -72,6 +72,13 @@ export interface CreateAntigravityPluginOptions {
7272
dependencies?: PluginDependencyOverrides
7373
}
7474

75+
export function registerQuotaManagerProducer(
76+
lifecycle: PluginLifecycle,
77+
quotaManager: QuotaManager,
78+
): void {
79+
lifecycle.register({ dispose: () => quotaManager.dispose() }, 'producer')
80+
}
81+
7582
export const createAntigravityPlugin =
7683
(providerId: string, options: CreateAntigravityPluginOptions = {}) =>
7784
async (input: PluginInput): Promise<PluginResult> => {
@@ -146,7 +153,7 @@ export const createAntigravityPlugin =
146153
// final post-refresh write is enqueued before the drain flushes —
147154
// a consumer-phase registration could let a refresh enqueue a write
148155
// after drainSidebarWrites() already asserted the queue was empty.
149-
lifecycle.register({ dispose: () => quotaManager.dispose() }, 'producer')
156+
registerQuotaManagerProducer(lifecycle, quotaManager)
150157

151158
// Operator settings controller backs the /antigravity-* slash commands.
152159
// The controller loads existing persisted settings at first read, mutates

packages/opencode/src/plugin/quota.test.ts

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
1+
import {
2+
afterEach,
3+
beforeEach,
4+
describe,
5+
expect,
6+
it,
7+
mock,
8+
spyOn,
9+
} from 'bun:test'
210
import { mkdtempSync, rmSync } from 'node:fs'
311
import { tmpdir } from 'node:os'
412
import { join } from 'node:path'
@@ -7,12 +15,21 @@ import type { AccountMetadataV3 } from '@cortexkit/antigravity-auth-core'
715

816
import {
917
DEFAULT_SIDEBAR_STATE,
18+
drainSidebarWrites,
1019
readSidebarState,
1120
SIDEBAR_STATE_ENV,
1221
SIDEBAR_STATE_VERSION,
1322
type SidebarStateV1,
23+
setSidebarMergeHooks,
1424
} from '../sidebar-state'
15-
import { classifyQuotaGroup, pushSidebarQuotaSnapshot } from './quota.ts'
25+
import { registerQuotaManagerProducer } from './index.ts'
26+
import { createPluginLifecycle } from './lifecycle.ts'
27+
import {
28+
classifyQuotaGroup,
29+
createOpenCodeQuotaManager,
30+
pushSidebarQuotaSnapshot,
31+
} from './quota.ts'
32+
import type { PluginClient } from './types.ts'
1633

1734
interface QuotaSnapshotAccount {
1835
index: number
@@ -154,4 +171,98 @@ describe('pushSidebarQuotaSnapshot', () => {
154171
const state = read()
155172
expect(state.accounts).toEqual([])
156173
})
174+
175+
it('fences the real quota wrapper sidebar enqueue before the lifecycle drain', async () => {
176+
const events: string[] = []
177+
let releaseFetch!: () => void
178+
const fetchGate = new Promise<void>((resolve) => {
179+
releaseFetch = resolve
180+
})
181+
let fetchStartedResolve!: () => void
182+
const fetchStarted = new Promise<void>((resolve) => {
183+
fetchStartedResolve = resolve
184+
})
185+
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(
186+
(async () =>
187+
new Response(
188+
JSON.stringify({ access_token: 'access-token', expires_in: 3600 }),
189+
{ status: 200 },
190+
)) as unknown as typeof fetch,
191+
)
192+
const client = {
193+
auth: { set: mock(async () => {}) },
194+
} as unknown as PluginClient
195+
const account: AccountMetadataV3 = {
196+
refreshToken: 'refresh-token',
197+
managedProjectId: 'managed-project',
198+
addedAt: 0,
199+
lastUsed: 0,
200+
}
201+
const manager = createOpenCodeQuotaManager(client, 'google', {
202+
getAccountsForSidebar: () => [
203+
{
204+
index: 0,
205+
email: 'primary@example.test',
206+
cachedQuota: {
207+
claude: { remainingFraction: 0.42, modelCount: 1 },
208+
},
209+
},
210+
],
211+
fetchVia: async () => {
212+
events.push('fetch:start')
213+
fetchStartedResolve()
214+
await fetchGate
215+
return new Response('unavailable', { status: 503 })
216+
},
217+
})
218+
const lifecycle = createPluginLifecycle({
219+
sessionRegistry: { clear: () => {} },
220+
shutdownDiskSignatureCache: async () => {},
221+
clearFetchState: () => {},
222+
drainSidebarWrites: async () => {
223+
events.push('lifecycle:drain')
224+
await drainSidebarWrites()
225+
events.push(
226+
readSidebarState(stateFile).accounts.length === 1
227+
? 'drain:sees-sidebar-write'
228+
: 'drain:misses-sidebar-write',
229+
)
230+
},
231+
})
232+
registerQuotaManagerProducer(lifecycle, manager)
233+
setSidebarMergeHooks({
234+
onStep: async (step) => {
235+
if (step === 'await-lock') events.push('sidebar:write-start')
236+
},
237+
})
238+
239+
const refresh = manager.refreshAccounts([account], {
240+
indexFor: () => 0,
241+
force: true,
242+
})
243+
await fetchStarted
244+
const dispose = lifecycle.dispose()
245+
releaseFetch()
246+
247+
try {
248+
await dispose
249+
await manager.refreshAccounts([account], {
250+
indexFor: () => 0,
251+
force: true,
252+
})
253+
await drainSidebarWrites()
254+
expect(events).toEqual([
255+
'fetch:start',
256+
'fetch:start',
257+
'sidebar:write-start',
258+
'lifecycle:drain',
259+
'drain:sees-sidebar-write',
260+
])
261+
} finally {
262+
await refresh
263+
await drainSidebarWrites()
264+
setSidebarMergeHooks(null)
265+
fetchSpy.mockRestore()
266+
}
267+
})
157268
})

packages/opencode/src/plugin/quota.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -140,35 +140,68 @@ export function createOpenCodeQuotaManager(
140140
const originalRefreshAccount = manager.refreshAccount
141141
const originalRefreshAccounts = manager.refreshAccounts
142142
const getAccountsForSidebar = options.getAccountsForSidebar
143+
let disposed = false
144+
const inFlight = new Set<Promise<unknown>>()
143145

144-
const pushAfterRefresh = (account: AccountMetadataV3): void => {
146+
const pushAfterRefresh = async (
147+
account: AccountMetadataV3,
148+
): Promise<void> => {
145149
if (!getAccountsForSidebar) return
146-
void pushSidebarQuotaSnapshot(
150+
await pushSidebarQuotaSnapshot(
147151
getAccountsForSidebar,
148152
manager.getBackoffUntil(account),
149153
).catch(() => {
150-
// pushSidebarQuotaSnapshot already swallows and logs; the .catch
151-
// here is just to keep the Promise from surfacing as an unhandled
152-
// rejection when the lock was retried past the 2s budget.
154+
// Sidebar persistence remains best-effort when lock contention
155+
// outlives its retry budget.
153156
})
154157
}
155158

159+
const track = <T>(operation: Promise<T>): Promise<T> => {
160+
inFlight.add(operation)
161+
void operation.then(
162+
() => inFlight.delete(operation),
163+
() => inFlight.delete(operation),
164+
)
165+
return operation
166+
}
167+
168+
const dispose = async (): Promise<void> => {
169+
if (disposed) return
170+
disposed = true
171+
await manager.dispose()
172+
await Promise.allSettled(inFlight)
173+
}
174+
156175
return {
157176
...manager,
158177
async refreshAccount(account, refreshOptions) {
159-
const result = await originalRefreshAccount(account, refreshOptions)
160-
pushAfterRefresh(account)
161-
return result
178+
const shouldPush = !disposed
179+
return track(
180+
(async () => {
181+
const result = await originalRefreshAccount(account, refreshOptions)
182+
if (shouldPush) await pushAfterRefresh(account)
183+
return result
184+
})(),
185+
)
162186
},
163187
async refreshAccounts(accounts, refreshOptions) {
164-
const results = await originalRefreshAccounts(accounts, refreshOptions)
165-
// Push one snapshot per batch — the AccountManager's view is updated
166-
// by the caller (oauth-methods / fetch-interceptor) BEFORE we read
167-
// here, so a single post-batch snapshot captures the full diff.
168-
const lastAccount = accounts[accounts.length - 1]
169-
if (lastAccount) pushAfterRefresh(lastAccount)
170-
return results
188+
const shouldPush = !disposed
189+
return track(
190+
(async () => {
191+
const results = await originalRefreshAccounts(
192+
accounts,
193+
refreshOptions,
194+
)
195+
// Push one snapshot per batch — the AccountManager's view is updated
196+
// by the caller (oauth-methods / fetch-interceptor) BEFORE we read
197+
// here, so a single post-batch snapshot captures the full diff.
198+
const lastAccount = accounts[accounts.length - 1]
199+
if (shouldPush && lastAccount) await pushAfterRefresh(lastAccount)
200+
return results
201+
})(),
202+
)
171203
},
204+
dispose,
172205
}
173206
}
174207

0 commit comments

Comments
 (0)