Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,20 @@ export async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T>
const prev = locks.get(key) ?? Promise.resolve()
let release!: () => void
const next = new Promise<void>(r => (release = r))
locks.set(
key,
prev.then(() => next),
)
const chained = prev.then(() => next)
locks.set(key, chained)
await prev.catch(() => {}) // wait our turn; ignore prior errors
try {
return await fn()
} finally {
release()
// Clean up if we're the tail of the chain to avoid unbounded growth.
if (locks.get(key) === next) locks.delete(key)
// Clean up if we're the tail of the chain to avoid unbounded growth. The
// map holds the chained promise, so the tail check must compare against it.
if (locks.get(key) === chained) locks.delete(key)
}
}

/** Number of keys currently held in the lock table. For tests/observability. */
export function activeLockCount(): number {
return locks.size
}
40 changes: 40 additions & 0 deletions tests/lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from 'bun:test'
import { activeLockCount, withLock } from '../src/lock.ts'

describe('withLock', () => {
test('serializes critical sections for the same key', async () => {
const order: number[] = []
let running = 0
let overlap = false
const task = (n: number) =>
withLock('k', async () => {
running++
if (running > 1) overlap = true
await Promise.resolve()
order.push(n)
running--
})
await Promise.all([task(1), task(2), task(3)])
expect(overlap).toBe(false)
expect(order).toEqual([1, 2, 3])
})

test('does not leak lock-table entries once sections complete', async () => {
// Distinct keys, each fully awaited: the table must return to empty.
for (let i = 0; i < 100; i++) {
await withLock(`ns:${i}`, async () => i)
}
// Concurrent contention on one key must also drain.
await Promise.all(Array.from({ length: 20 }, () => withLock('hot', async () => 1)))
expect(activeLockCount()).toBe(0)
})

test('cleans up even when the critical section throws', async () => {
await expect(
withLock('boom', async () => {
throw new Error('fail')
}),
).rejects.toThrow('fail')
expect(activeLockCount()).toBe(0)
})
})
Loading