fix: memoize in-flight acquire so concurrent acquire() is re-entrant-safe (E00-S03-T04)
CI / Frozen lockfile install (pull_request) Successful in 49s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 26s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 26s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 45s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 50s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s

Security-review finding F2: two concurrent acquire() calls on the same
MigrationLock instance could each check out a connection; the second
pg_advisory_lock would overwrite this.client, leaking the first locked
connection until session end.

acquire() now memoizes the in-flight acquire in acquireInFlight and
returns it on re-entry, so exactly one connection is checked out and no
locked connection leaks. The memo is cleared once the acquire settles.
tryAcquire()/release() paths unchanged.

Locked in by:
- static criterion test: acquireInFlight field, re-entry guard returns
  the in-flight acquire, memo cleared on settle
- mutation probe: removing the re-entry guard fails the criterion
- real-stack probe: two concurrent acquire() calls on one instance leave
  pool.totalCount at 1 (exactly one connection), the lock granted once,
  nothing left after release; probe fails (hangs) on the pre-fix code
- CI job comment updated to reflect the re-entrancy criterion
This commit is contained in:
implementer
2026-08-30 00:29:20 +00:00
parent a2b98439e9
commit dac3679e33
3 changed files with 150 additions and 20 deletions
+23 -3
View File
@@ -47,6 +47,8 @@ export class MigrationLock {
private readonly pool: Pool;
private client: PoolClient | null = null;
private held = false;
/** In-flight acquire, memoized so concurrent acquire() calls share one connection (re-entrant-safe). */
private acquireInFlight: Promise<void> | null = null;
/** @param pool The package-owned PostgreSQL pool (`pg.Pool`). */
constructor(pool: Pool) {
@@ -61,12 +63,30 @@ export class MigrationLock {
/**
* Acquires the migration advisory lock, blocking until it is free — a
* second runner waits here while the first holds the lock. Idempotent:
* acquiring an already-held instance is a no-op. The lock is held on a
* dedicated connection until `release()` (or until the session ends — e.g.
* the runner exits and the pool closes its connections).
* acquiring an already-held instance is a no-op. Re-entrant-safe:
* concurrent `acquire()` calls on the same instance share the single
* in-flight acquire (memoized in `acquireInFlight`), so exactly one
* connection is checked out and no locked connection leaks. The lock is
* held on a dedicated connection until `release()` (or until the session
* ends — e.g. the runner exits and the pool closes its connections).
*/
async acquire(): Promise<void> {
if (this.held) return;
// A second concurrent acquire() on this instance returns the in-flight
// acquire instead of checking out another connection: exactly one
// connection is checked out and no locked connection leaks.
if (this.acquireInFlight !== null) return this.acquireInFlight;
const inFlight = this.doAcquire();
this.acquireInFlight = inFlight;
try {
await inFlight;
} finally {
this.acquireInFlight = null;
}
}
/** The memoized acquire body: checks out one dedicated connection and takes the session-scoped lock on it. */
private async doAcquire(): Promise<void> {
const client = await this.pool.connect();
try {
await client.query(