Skip to content

Commit 97e5957

Browse files
committed
fix: resolve remaining branch review musts (tui barrel, lock toctou, debug leak, killswitch, quota producer)
1 parent fac4c70 commit 97e5957

20 files changed

Lines changed: 627 additions & 58 deletions

ARCHITECTURE.md

Lines changed: 31 additions & 20 deletions
Large diffs are not rendered by default.

STRUCTURE.md

Lines changed: 13 additions & 12 deletions
Large diffs are not rendered by default.

packages/core/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@
1616
".": {
1717
"import": "./dist/index.js",
1818
"types": "./dist/index.d.ts"
19+
},
20+
"./file-lock": {
21+
"types": "./dist/file-lock.d.ts",
22+
"import": "./dist/file-lock.js"
23+
},
24+
"./atomic-write": {
25+
"types": "./dist/atomic-write.d.ts",
26+
"import": "./dist/atomic-write.js"
27+
},
28+
"./fetch-timeout": {
29+
"types": "./dist/fetch-timeout.d.ts",
30+
"import": "./dist/fetch-timeout.js"
1931
}
2032
},
2133
"files": [

packages/core/src/account-manager.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,15 @@ export class AccountManager {
691691
softQuotaThresholdPercent: number = 100,
692692
softQuotaCacheTtlMs: number = 10 * 60 * 1000,
693693
identity?: AccountSessionIdentity,
694+
/**
695+
* Account indexes the caller has ruled out (e.g. the operator
696+
* killswitch pre-filter). Every selection path — pinned session,
697+
* round-robin, hybrid, and sticky fallback — skips these indexes
698+
* so a killed current account falls through to the next eligible
699+
* account instead of collapsing the request into the rate-limit
700+
* wait path.
701+
*/
702+
excludeIndexes?: Set<number>,
694703
): ManagedAccount | null {
695704
const quotaKey = getQuotaKey(family, headerStyle, model)
696705
const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(
@@ -704,6 +713,7 @@ export class AccountManager {
704713
if (pinned) {
705714
clearExpiredRateLimits(pinned, this.now)
706715
const unavailable =
716+
(excludeIndexes?.has(pinned.index) ?? false) ||
707717
isRateLimitedForHeaderStyle(
708718
pinned,
709719
family,
@@ -735,6 +745,7 @@ export class AccountManager {
735745
effectiveSoftQuotaThreshold,
736746
softQuotaCacheTtlMs,
737747
identity,
748+
excludeIndexes,
738749
)
739750
if (next) {
740751
this.markTouchedForQuota(next, quotaKey)
@@ -748,7 +759,9 @@ export class AccountManager {
748759
const tokenTracker = getTokenTracker()
749760

750761
const eligibleAccounts = this.preferAccountOutsideParent(
751-
this.accounts.filter((acc) => acc.enabled !== false),
762+
this.accounts.filter(
763+
(acc) => acc.enabled !== false && !excludeIndexes?.has(acc.index),
764+
),
752765
family,
753766
identity,
754767
)
@@ -825,7 +838,7 @@ export class AccountManager {
825838
}
826839

827840
const current = this.getCurrentAccountForFamily(family, identity)
828-
if (current) {
841+
if (current && !excludeIndexes?.has(current.index)) {
829842
clearExpiredRateLimits(current, this.now)
830843
const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(
831844
current,
@@ -859,6 +872,7 @@ export class AccountManager {
859872
effectiveSoftQuotaThreshold,
860873
softQuotaCacheTtlMs,
861874
identity,
875+
excludeIndexes,
862876
)
863877
if (next) {
864878
this.markTouchedForQuota(next, quotaKey)
@@ -874,6 +888,8 @@ export class AccountManager {
874888
softQuotaThresholdPercent: number = 100,
875889
softQuotaCacheTtlMs: number = 10 * 60 * 1000,
876890
identity?: AccountSessionIdentity,
891+
/** Indexes ruled out by the caller (e.g. killswitch pre-filter). */
892+
excludeIndexes?: Set<number>,
877893
): ManagedAccount | null {
878894
const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(
879895
softQuotaThresholdPercent,
@@ -882,6 +898,7 @@ export class AccountManager {
882898
clearExpiredRateLimits(account, this.now)
883899
return (
884900
account.enabled !== false &&
901+
!excludeIndexes?.has(account.index) &&
885902
!isRateLimitedForHeaderStyle(
886903
account,
887904
family,

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,116 @@ describe('acquireFencedFileLock — renewal TOCTOU', () => {
893893
await rm(lockPath, { force: true })
894894
})
895895

896+
it('marks the lock lost when a replacement owner arrives between the renewal read and rename', async () => {
897+
const target = join(root, 'state.json')
898+
const lockPath = `${target}.accounts.lock`
899+
const clearIntervalSpy = spyOn(globalThis, 'clearInterval')
900+
901+
let pausedResolve!: () => void
902+
const paused = new Promise<void>((resolve) => {
903+
pausedResolve = resolve
904+
})
905+
let gateResolve!: () => void
906+
const gate = new Promise<void>((resolve) => {
907+
gateResolve = resolve
908+
})
909+
let hookFired = false
910+
911+
try {
912+
const lock = await acquireFencedFileLock({
913+
path: target,
914+
name: 'accounts',
915+
ttlMs: 60_000,
916+
renewIntervalMs: 10,
917+
onStep: async (step) => {
918+
if (step === 'renew-read' && !hookFired) {
919+
hookFired = true
920+
pausedResolve()
921+
await gate
922+
}
923+
},
924+
})
925+
expect(lock).not.toBeNull()
926+
927+
// Wait for owner A's renewal tick to reach the read seam, then
928+
// owner B takes over between A's read and A's rename.
929+
await paused
930+
await writeLock(lockPath, 'owner-B', Date.now() + 60_000)
931+
gateResolve()
932+
933+
await lock!.whenLost()
934+
expect(lock!.hasLost()).toBe(true)
935+
// markLost() cleared the renewal interval exactly once.
936+
expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
937+
938+
// A's pre-rename re-read must catch the takeover before the
939+
// rename — B's lock is intact, not clobbered by A's renewal.
940+
const contents = await readLock(lockPath)
941+
expect(contents?.ownerId).toBe('owner-B')
942+
943+
await lock!.release()
944+
await rm(lockPath, { force: true })
945+
} finally {
946+
clearIntervalSpy.mockRestore()
947+
}
948+
})
949+
950+
it('marks the lock lost when a takeover lands right after the renewal rename (verify-after-commit)', async () => {
951+
const target = join(root, 'state.json')
952+
const lockPath = `${target}.accounts.lock`
953+
const clearIntervalSpy = spyOn(globalThis, 'clearInterval')
954+
955+
let pausedResolve!: () => void
956+
const paused = new Promise<void>((resolve) => {
957+
pausedResolve = resolve
958+
})
959+
let gateResolve!: () => void
960+
const gate = new Promise<void>((resolve) => {
961+
gateResolve = resolve
962+
})
963+
let hookFired = false
964+
965+
try {
966+
const lock = await acquireFencedFileLock({
967+
path: target,
968+
name: 'accounts',
969+
ttlMs: 60_000,
970+
renewIntervalMs: 10,
971+
onStep: async (step) => {
972+
if (step === 'renew-committed' && !hookFired) {
973+
hookFired = true
974+
pausedResolve()
975+
await gate
976+
}
977+
},
978+
})
979+
expect(lock).not.toBeNull()
980+
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.
984+
await paused
985+
await writeLock(lockPath, 'owner-B', Date.now() + 60_000)
986+
gateResolve()
987+
988+
await lock!.whenLost()
989+
expect(lock!.hasLost()).toBe(true)
990+
expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
991+
992+
// A backed off after the verify re-read saw B; B's write is the
993+
// final content and A's release refuses to delete it.
994+
const contents = await readLock(lockPath)
995+
expect(contents?.ownerId).toBe('owner-B')
996+
997+
await lock!.release()
998+
const afterRelease = await readLock(lockPath)
999+
expect(afterRelease?.ownerId).toBe('owner-B')
1000+
await rm(lockPath, { force: true })
1001+
} finally {
1002+
clearIntervalSpy.mockRestore()
1003+
}
1004+
})
1005+
8961006
it('whenLost() resolves promptly when the lock is taken over mid-renewal', async () => {
8971007
const target = join(root, 'state.json')
8981008
const lockPath = `${target}.accounts.lock`

packages/core/src/file-lock.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export type FileLockStep =
4141
| 'stale-marker-claimed'
4242
| 'stale-lock-confirmed'
4343
| 'eviction-marker-acquired'
44+
| 'renew-read'
45+
| 'renew-committed'
4446

4547
export interface FencedFileLockOptions {
4648
path: string
@@ -62,8 +64,13 @@ export interface FencedFileLockOptions {
6264
now?: () => number
6365
/**
6466
* Hook invoked at well-defined milestones inside the eviction
65-
* protocol. Used by tests to inject interleavings and simulate
66-
* racing contenders.
67+
* protocol and the renewal tick. Used by tests to inject
68+
* interleavings and simulate racing contenders:
69+
*
70+
* - `renew-read` — after the renewal tick reads the lock payload,
71+
* before the ownership checks and the temp-write/rename commit.
72+
* - `renew-committed` — after the renewal rename lands, before the
73+
* verify-after-commit re-read.
6774
*/
6875
onStep?: (step: FileLockStep) => Promise<void> | void
6976
}
@@ -373,11 +380,12 @@ function buildLock(
373380
try {
374381
const observed = await readLockPayload(lockPath)
375382
if (released || lost) return
383+
await options.onStep?.('renew-read')
384+
if (released || lost) return
376385
// If the lock file is gone or carries a different ownerId, the
377-
// lock has been taken over. Mark it lost so the next iteration
378-
// stops renewing — this is the case the dispatch's TOCTOU test
379-
// exercises (owner B evicts and replaces the lock while owner
380-
// A's renewal is paused).
386+
// lock has been taken over (e.g. owner B evicted and replaced
387+
// the lock while this renewal was paused). Mark it lost so the
388+
// next iteration stops renewing.
381389
if (observed === null) {
382390
markLost()
383391
return
@@ -424,6 +432,21 @@ function buildLock(
424432
}
425433
shouldCommit = true
426434
await rename(tempPath, lockPath)
435+
await options.onStep?.('renew-committed')
436+
if (released || lost) return
437+
// Verify-after-commit: a replacement owner may have arrived
438+
// between the re-read above and the rename — our rename then
439+
// just overwrote their fresh lock — or may land a microsecond
440+
// after our rename. Re-read and, if the file no longer carries
441+
// our ownerId, mark lost and stop renewing so the double-owner
442+
// window collapses to this seam; the fresh owner's next
443+
// renewal/acquire attempt re-acquires.
444+
const committed = await readLockPayload(lockPath)
445+
if (released || lost) return
446+
if (!committed || committed.ownerId !== ownerId) {
447+
markLost()
448+
return
449+
}
427450
} catch {
428451
// Lost the race against a rename/evict — mark lost so the
429452
// next iteration stops renewing, and clean up any temp draft.

packages/core/src/quota-manager.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, describe, expect, it } from 'bun:test'
22
import type { AccountMetadataV3 } from './account-types.ts'
33
import { createQuotaManager, type FetchAccountQuota } from './quota-manager.ts'
4+
import type { AccountQuotaResult } from './quota-types.ts'
45

56
function makeAccount(
67
overrides: Partial<AccountMetadataV3> = {},
@@ -590,6 +591,61 @@ describe('dispose', () => {
590591
const next = await manager.refreshAccount(account, { index: 0 })
591592
expect(next.status).toBe('error')
592593
})
594+
595+
it('awaits the in-flight refresh before resolving so a post-refresh side effect is fenced', async () => {
596+
// A producer (the opencode quota wrapper) fires a fire-and-forget
597+
// sidebar write in the continuation after refreshAccount resolves.
598+
// dispose() must not resolve until that in-flight refresh has
599+
// settled, so the continuation's write is enqueued before the
600+
// lifecycle drains the sidebar queue in the following phase. A
601+
// side-effect attached to the refresh promise stands in for that
602+
// continuation here.
603+
const order: string[] = []
604+
let resolveFetch: ((result: AccountQuotaResult) => void) | null = null
605+
const fetch: FetchAccountQuota = (account) => {
606+
order.push('fetch:start')
607+
return new Promise<AccountQuotaResult>((resolve) => {
608+
resolveFetch = () =>
609+
resolve({
610+
index: 0,
611+
status: 'ok',
612+
email: account.email,
613+
quota: { groups: {}, modelCount: 0 },
614+
})
615+
})
616+
}
617+
618+
const manager = createQuotaManager({
619+
fetchAccountQuota: fetch,
620+
keyOf: keyOfAccount,
621+
})
622+
const account = makeAccount({ email: 'inflight@example.com' })
623+
624+
let sideEffectRan = false
625+
const pending = manager
626+
.refreshAccount(account, { index: 0 })
627+
.then((result) => {
628+
// Stand-in for the wrapper's fire-and-forget sidebar write.
629+
sideEffectRan = true
630+
order.push('side-effect')
631+
return result
632+
})
633+
634+
// The fetch is now mid-flight (awaiting resolveFetch). Kick off
635+
// dispose, then release the fetch — dispose must await the in-flight
636+
// refresh and its continuation before resolving.
637+
const disposed = manager.dispose().then(() => {
638+
order.push('dispose:resolved')
639+
// The producer's side-effect was scheduled before dispose resolved.
640+
expect(sideEffectRan).toBe(true)
641+
})
642+
resolveFetch?.()
643+
644+
await Promise.all([disposed, pending])
645+
646+
// fetch:start → (in-flight refresh settles + side-effect) → dispose.
647+
expect(order).toEqual(['fetch:start', 'side-effect', 'dispose:resolved'])
648+
})
593649
})
594650

595651
describe('refreshAccounts', () => {

0 commit comments

Comments
 (0)