Skip to content

Commit fac4c70

Browse files
committed
fix: address branch review musts (lock, tui isolation, e2e guard, security, killswitch, lifecycle)
Lock: stop renewal on ownership loss, idempotent terminal release, eviction marker TTL with PID/createdAt. TUI isolation: import-graph gate in build-tui.test.ts. E2E: unconditional loopback-only fetch deny guard with per-test install/restore. Security: redact project IDs and fingerprints in debug/dump output, enforce 0600 on existing TUI log files. Killswitch: model-aware evaluation, eligibleAccounts wired into selection, fallback recheck. Lifecycle: producers disposed before sidebar drain, consumers after. Tests: all locks released in afterEach, lifecycle disposed, sidebar release awaited.
1 parent fb62fea commit fac4c70

22 files changed

Lines changed: 1376 additions & 162 deletions

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
"scripts": {
99
"build": "bun run --cwd packages/core build && bun run --cwd packages/opencode build && bun run --cwd packages/pi build",
1010
"typecheck": "bun run --cwd packages/core build && bun run --cwd packages/opencode typecheck && bun run --cwd packages/pi typecheck && tsc -p tsconfig.scripts.json",
11-
"test": "bun run --cwd packages/core build && bun test --isolate",
12-
"test:e2e": "bun test --isolate ./packages/e2e-tests/src/plugin-flow.e2e.test.ts ./packages/e2e-tests/src/cli-flow.e2e.test.ts ./packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts",
11+
"test": "bun run --cwd packages/core build && bun test --isolate packages/core/src packages/opencode/src packages/pi/src test/",
12+
"test:e2e": "bun test --isolate ./packages/e2e-tests/src/plugin-flow.e2e.test.ts ./packages/e2e-tests/src/cli-flow.e2e.test.ts ./packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts ./packages/e2e-tests/src/fetch-guard.test.ts",
1313
"test:e2e:models": "bun run --cwd packages/opencode test:e2e:models",
1414
"test:e2e:regression": "bun run --cwd packages/opencode test:e2e:regression",
1515
"prepublishOnly": "bun run build",

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

Lines changed: 242 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,31 @@ import { tmpdir } from 'node:os'
1313
import { join } from 'node:path'
1414

1515
import {
16-
acquireFencedFileLock,
16+
acquireFencedFileLock as acquireFencedFileLockUntracked,
17+
type FencedFileLock,
18+
type FencedFileLockOptions,
1719
FileLockOwnershipError,
1820
type FileLockStep,
1921
} from './file-lock.ts'
2022

2123
let root: string
24+
const activeLocks = new Set<FencedFileLock>()
25+
26+
async function acquireFencedFileLock(
27+
options: FencedFileLockOptions,
28+
): Promise<FencedFileLock | null> {
29+
const lock = await acquireFencedFileLockUntracked(options)
30+
if (lock) activeLocks.add(lock)
31+
return lock
32+
}
2233

2334
beforeEach(async () => {
2435
root = await mkdtemp(join(tmpdir(), 'file-lock-'))
2536
})
2637

2738
afterEach(async () => {
39+
await Promise.all(Array.from(activeLocks, (lock) => lock.release()))
40+
activeLocks.clear()
2841
await rm(root, { recursive: true, force: true })
2942
})
3043

@@ -351,7 +364,7 @@ describe('acquireFencedFileLock — release deletes only its own lock', () => {
351364
// stuck mid-writeFile) when we trigger release().
352365
await new Promise((resolve) => setTimeout(resolve, 30))
353366

354-
await lock.release()
367+
await lock!.release()
355368

356369
// With the fix: release awaits the in-flight renewal, then unlinks.
357370
// Without the fix: release unlinks first, the still-pending renewal
@@ -688,3 +701,230 @@ describe('acquireFencedFileLock — onStep interleavings', () => {
688701
expect(observed).toEqual([])
689702
})
690703
})
704+
705+
describe('acquireFencedFileLock — eviction marker TTL/PID reclamation', () => {
706+
it('reclaims a stale eviction marker whose PID is dead', async () => {
707+
const target = join(root, 'state.json')
708+
const lockPath = `${target}.accounts.lock`
709+
const evictingDir = `${lockPath}.evicting`
710+
const markerPath = join(evictingDir, 'owner.json')
711+
712+
// Drop a stale marker with a dead PID (init(1) is always present on
713+
// POSIX but its recycled quit claim is rare; use a clearly bogus PID
714+
// to guarantee `process.kill(pid, 0)` throws ESRCH).
715+
await mkdir(evictingDir, { recursive: true, mode: 0o700 })
716+
await writeFile(
717+
markerPath,
718+
JSON.stringify({
719+
ownerId: 'dead-evicter',
720+
pid: 2_000_000_000,
721+
createdAt: Date.now() - 60_000,
722+
}),
723+
'utf8',
724+
)
725+
726+
// The lock itself is genuinely stale, so the contender will hit
727+
// the eviction path. With the stale-marker reclamation logic, the
728+
// first waiter removes the dead-PID marker and proceeds.
729+
await writeLock(lockPath, 'zombie', Date.now() - 60_000)
730+
731+
const lock = await acquireFencedFileLock({
732+
path: target,
733+
name: 'accounts',
734+
ttlMs: 60_000,
735+
})
736+
expect(lock).not.toBeNull()
737+
// The lock is now ours — proves the reclamation worked.
738+
const contents = await readLock(lockPath)
739+
expect(contents?.ownerId).toBe(lock!.ownerId)
740+
await lock!.release()
741+
})
742+
743+
it('reclaims a marker older than 30 seconds regardless of PID liveness', async () => {
744+
const target = join(root, 'state.json')
745+
const lockPath = `${target}.accounts.lock`
746+
const evictingDir = `${lockPath}.evicting`
747+
const markerPath = join(evictingDir, 'owner.json')
748+
749+
// Marker is fresh-but-just-tipping-over the TTL. Use our own pid
750+
// so the liveness check is positive — only the age triggers the
751+
// reclaim.
752+
await mkdir(evictingDir, { recursive: true, mode: 0o700 })
753+
await writeFile(
754+
markerPath,
755+
JSON.stringify({
756+
ownerId: 'old-but-alive',
757+
pid: process.pid,
758+
createdAt: Date.now() - 31_000,
759+
}),
760+
'utf8',
761+
)
762+
763+
await writeLock(lockPath, 'zombie', Date.now() - 60_000)
764+
765+
const lock = await acquireFencedFileLock({
766+
path: target,
767+
name: 'accounts',
768+
ttlMs: 60_000,
769+
})
770+
expect(lock).not.toBeNull()
771+
const contents = await readLock(lockPath)
772+
expect(contents?.ownerId).toBe(lock!.ownerId)
773+
await lock!.release()
774+
})
775+
776+
it('respects a fresh, live marker (does not reclaim an in-progress eviction)', async () => {
777+
const target = join(root, 'state.json')
778+
const lockPath = `${target}.accounts.lock`
779+
const evictingDir = `${lockPath}.evicting`
780+
const markerPath = join(evictingDir, 'owner.json')
781+
782+
// Live marker — fresh AND alive. The contender must respect it.
783+
await mkdir(evictingDir, { recursive: true, mode: 0o700 })
784+
await writeFile(
785+
markerPath,
786+
JSON.stringify({
787+
ownerId: 'live-evicter',
788+
pid: process.pid,
789+
createdAt: Date.now(),
790+
}),
791+
'utf8',
792+
)
793+
794+
await writeLock(lockPath, 'zombie', Date.now() - 60_000)
795+
796+
const lock = await acquireFencedFileLock({
797+
path: target,
798+
name: 'accounts',
799+
ttlMs: 60_000,
800+
})
801+
// Bounded retries exhausted — the contender backs off.
802+
expect(lock).toBeNull()
803+
// The live marker is still in place.
804+
const stillThere = await readFile(markerPath, 'utf8')
805+
expect(JSON.parse(stillThere).ownerId).toBe('live-evicter')
806+
807+
// Clean up.
808+
await rm(evictingDir, { recursive: true, force: true })
809+
await rm(lockPath, { force: true })
810+
})
811+
})
812+
813+
describe('acquireFencedFileLock — renewal TOCTOU', () => {
814+
it('makes release terminal and resolves existing whenLost waiters', async () => {
815+
const target = join(root, 'state.json')
816+
const lockPath = `${target}.accounts.lock`
817+
const lock = await acquireFencedFileLock({
818+
path: target,
819+
name: 'accounts',
820+
ttlMs: 60_000,
821+
renewIntervalMs: 10,
822+
})
823+
expect(lock).not.toBeNull()
824+
const lost = lock!.whenLost()
825+
826+
await lock!.release()
827+
828+
await expect(lost).resolves.toBeUndefined()
829+
await expect(lock!.whenLost()).resolves.toBeUndefined()
830+
expect(lock!.hasLost()).toBe(true)
831+
expect(await readLock(lockPath)).toBeNull()
832+
await lock!.release()
833+
})
834+
835+
it('stops the renewal interval as soon as ownership is lost', async () => {
836+
const target = join(root, 'state.json')
837+
const lockPath = `${target}.accounts.lock`
838+
const clearIntervalSpy = spyOn(globalThis, 'clearInterval')
839+
840+
try {
841+
const lock = await acquireFencedFileLock({
842+
path: target,
843+
name: 'accounts',
844+
ttlMs: 60_000,
845+
renewIntervalMs: 10,
846+
})
847+
expect(lock).not.toBeNull()
848+
849+
await writeLock(lockPath, 'thief', Date.now() + 60_000)
850+
await lock!.whenLost()
851+
852+
expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
853+
await lock!.release()
854+
} finally {
855+
clearIntervalSpy.mockRestore()
856+
await rm(lockPath, { force: true })
857+
}
858+
})
859+
860+
it('detects ownership change between async read and write — does not clobber the new owner', async () => {
861+
const target = join(root, 'state.json')
862+
const lockPath = `${target}.accounts.lock`
863+
864+
const lock = await acquireFencedFileLock({
865+
path: target,
866+
name: 'accounts',
867+
ttlMs: 60_000,
868+
renewIntervalMs: 10,
869+
})
870+
expect(lock).not.toBeNull()
871+
872+
// Owner B evicts A and takes over the lock with a live expiry.
873+
// This is the "A pauses between read and write" moment: the moment
874+
// A's renewal is mid-cycle, B rips the lock file out and rewrites
875+
// it with B's ownerId. A's renewal must NOT clobber B's lock.
876+
await writeLock(lockPath, 'owner-B', Date.now() + 60_000)
877+
878+
// Wait for A's renewal tick to fire and notice the mismatch.
879+
await lock!.whenLost()
880+
881+
// B's lock must be intact — A's renewal must NOT have overwritten it.
882+
const contents = await readLock(lockPath)
883+
expect(contents?.ownerId).toBe('owner-B')
884+
885+
// A must report itself as lost.
886+
expect(lock!.hasLost()).toBe(true)
887+
888+
// Release should refuse to delete (the lock is B's).
889+
await lock!.release()
890+
const afterRelease = await readLock(lockPath)
891+
expect(afterRelease?.ownerId).toBe('owner-B')
892+
893+
await rm(lockPath, { force: true })
894+
})
895+
896+
it('whenLost() resolves promptly when the lock is taken over mid-renewal', async () => {
897+
const target = join(root, 'state.json')
898+
const lockPath = `${target}.accounts.lock`
899+
900+
const lock = await acquireFencedFileLock({
901+
path: target,
902+
name: 'accounts',
903+
ttlMs: 60_000,
904+
renewIntervalMs: 10,
905+
})
906+
expect(lock).not.toBeNull()
907+
expect(lock!.hasLost()).toBe(false)
908+
909+
// Write a different owner into the lock. Note: this happens AFTER
910+
// the initial acquire, so ownership is "fresh-stolen" — the renewal
911+
// must detect and bail.
912+
await writeLock(lockPath, 'thief', Date.now() + 60_000)
913+
914+
// Bounded wait — if whenLost() never resolves, the pipeline is
915+
// broken. 500ms is generous for the 10ms tick + sync re-read.
916+
const lostPromise = lock!.whenLost()
917+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
918+
const timeout = new Promise<'timeout'>((resolve) => {
919+
timeoutHandle = setTimeout(() => resolve('timeout'), 1000)
920+
})
921+
const result = await Promise.race([lostPromise, timeout]).finally(() => {
922+
if (timeoutHandle !== null) clearTimeout(timeoutHandle)
923+
})
924+
expect(result).toBeUndefined()
925+
expect(lock!.hasLost()).toBe(true)
926+
927+
await lock!.release()
928+
await rm(lockPath, { force: true })
929+
})
930+
})

0 commit comments

Comments
 (0)