SQLite withTransaction: interrupting a queued transaction as it gets the connection leaks the connection
I maintainer di solito rispondono entro 1 giorno
Una pull request collegata è già stata integrata.
- #9962 di @effect-bot — integrata
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 1-2 giorni
- Idoneità per principianti
- 35/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Ferma
- Stack tecnologico
- node.js, sqlite, typescript
Direzione di ricerca
Read makeSqliteAcquirers and makeWithTransaction, especially the lease and its interruptibility around Semaphore.take and finalizer registration; the issue also identifies sql.reserve as a possible affected entry point. Use the supplied lease reproduction to check whether interruption can leak a permit, then verify that a later permit acquisition completes after the holder releases it. An open linked pull request (#9962) is already addressing this issue.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
What version of Effect is running?
[email protected]and@effect/[email protected]on Node 26.12.1 (Linux x64), unmodified published packages.makeSqliteAcquirersandmakeWithTransactionare unchanged onmainsince[email protected].- Introduced by #9517 (the fix for #9514), which is first released in 4.0.6.
What steps can reproduce the bug?
Fiber A runs a statement, so it holds the SQLite connection. Fiber B calls withTransaction and queues behind it. B is interrupted around the moment A hands it the connection. MaxOpsBeforeYield and a few yieldNows place the interrupt; a budget sweep finds the window deterministically.
// repro.ts - node repro.ts
import { SqliteClient } from "@effect/sql-sqlite-node"
import { Effect, Fiber, Scheduler } from "effect"
import * as SqlClient from "effect/sql/SqlClient"
// One run: B queues behind A for the connection and is interrupted around the hand-off.
const attempt = (budget: number, lead: number) =>
Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
const holder = yield* Effect.forkChild(sql`SELECT 1`)
const queued = yield* Effect.forkChild(
sql.withTransaction(sql`SELECT 2`).pipe(Effect.provideService(Scheduler.MaxOpsBeforeYield, budget))
)
for (let i = 0; i < lead; i++) yield* Effect.yieldNow
yield* Fiber.interrupt(queued)
yield* Fiber.join(holder)
// Every later statement waits for the connection forever.
return yield* sql`SELECT 3`.pipe(Effect.timeoutOption("300 millis"), Effect.map((o) => o._tag === "Some"))
}).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" })))
const program = Effect.gen(function*() {
for (let budget = 3; budget <= 64; budget++) {
for (let lead = 0; lead <= 3; lead++) {
if (!(yield* attempt(budget, lead))) return `wedged at budget=${budget} lead=${lead}`
}
}
return "never wedged"
})
Effect.runPromise(program).then(console.log)
What is the expected behavior?
An interrupted withTransaction that never started its transaction leaves the connection free, and SELECT 3 runs. The script prints never wedged.
What do you see instead?
wedged at budget=21 lead=1, on every run. From then on every statement on that client waits for the connection until the layer is torn down. In a server, a client abort that lands in this window wedges the database until a restart.
Additional information
The permit leaks in the lease that makeSqliteAcquirers builds:
const lease = Effect.uninterruptibleMask((restore) =>
Effect.flatMap(Effect.scope, (scope) =>
restore(semaphore.take(1)).pipe(
Effect.andThen(Scope.addFinalizer(scope, semaphore.release(1))),
Effect.andThen(available)
))
)
Semaphore.take adds to taken inside the region restore makes interruptible, and the release is registered only after that region ends. If the fiber yields between the two and is interrupted there, the permit is taken but never released. Before #9517, makeWithTransaction called the acquirer while uninterruptible, so this restore restored an uninterruptible region and the window couldn't open; #9517 wraps the acquirer in the transaction's own restore, which opens it. sql.reserve, which uses the same lease, can probably hit it from any interruptible caller.
The lease alone reproduces it, without SQL:
import { Effect, Fiber, Scheduler, Scope, Semaphore } from "effect"
// The lease from makeSqliteAcquirers, called from a fiber that can be interrupted.
const lease = (sem: Semaphore.Semaphore) =>
Effect.uninterruptibleMask((restore) =>
Effect.flatMap(Effect.scope, (scope) =>
restore(sem.take(1)).pipe(Effect.andThen(Scope.addFinalizer(scope, sem.release(1))))
)
)
const attempt = (budget: number, lead: number) =>
Effect.gen(function*() {
const sem = yield* Semaphore.make(1)
const holder = yield* Effect.forkChild(sem.withPermit(Effect.yieldNow))
const queued = yield* Effect.forkChild(
Effect.scoped(lease(sem)).pipe(Effect.provideService(Scheduler.MaxOpsBeforeYield, budget))
)
for (let i = 0; i < lead; i++) yield* Effect.yieldNow
yield* Fiber.interrupt(queued)
yield* Fiber.join(holder)
return yield* sem.withPermit(Effect.void).pipe(Effect.timeoutOption("50 millis"), Effect.map((o) => o._tag === "Some"))
})
const program = Effect.gen(function*() {
for (let budget = 3; budget <= 64; budget++) {
for (let lead = 0; lead <= 3; lead++) {
if (!(yield* attempt(budget, lead))) return `permit leaked at budget=${budget} lead=${lead}`
}
}
return "never leaked"
})
Effect.runPromise(program).then(console.log)
This prints permit leaked at budget=6 lead=2. Semaphore.withPermits doesn't have the problem: it waits for a free permit interruptibly, then takes it after the interruptible region has ended. Taking the permit the same way in the lease, or making take hand the permit back when the fiber is interrupted before take returns, would close the window.
- Lingua principale
- TypeScript
- Stelle
- 16.7k
- Fork
- 808
- Merge medio
- 11h 33m
- PR unite (30g)
- 855
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di Effect-TS/effect
-
`fromJsonSchemaDocument`: import open `patternProperties`Forse già presa @effect-bot l’ha presa oggi. Aperta
Effect-TS/effect#10048 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
`fromJsonSchemaDocument`: intersect an object with `anyOf` branches that re-declare an enum propertyAperta
Difficoltà 4/5 3-5 giorni Idoneità per principianti 42/100
I maintainer di solito rispondono entro 1 giorno
-
`fromJsonSchemaDocument`: import `dependentRequired` (draft-07 `dependencies`)Forse già presa @effect-bot l’ha presa oggi. Aperta
Effect-TS/effect#10046 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
`fromJsonSchemaDocument`: import `if` / `then` / `else`Forse già presa @effect-bot l’ha presa oggi. Aperta
Effect-TS/effect#10045 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
`fromJsonSchemaDocument`: import the `not` keywordForse già presa @effect-bot l’ha presa oggi. Aperta
Effect-TS/effect#10044 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di Effect-TS/effect
Issue simili
-
[Integration]: eRPCAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
ava-labs/builders-hub#4826 ·
I maintainer di solito rispondono entro 1 giorno
-
记忆分析的默认模型显示与实际使用不一致Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 66/100
anomalyco/opencode#59886 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 85/100
-
a10y a10y-audit ai-driven-qa claude
Difficoltà 2/5 1-3 ore Idoneità per principianti 86/100
I maintainer di solito rispondono entro 1 giorno