Skip to content

Fix Semaphore.withPermits leaking permits when interrupted - #6910

Merged
tim-smart merged 2 commits into
Effect-TS:mainfrom
z4p5a9:fix/semaphore-with-permits-interrupt-leak
Aug 3, 2026
Merged

Fix Semaphore.withPermits leaking permits when interrupted#6910
tim-smart merged 2 commits into
Effect-TS:mainfrom
z4p5a9:fix/semaphore-with-permits-interrupt-leak

Conversation

@z4p5a9

@z4p5a9 z4p5a9 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fix Semaphore.withPermits leaking permits when interrupted during acquisition

Type

  • Refactor
  • Feature
  • Bug Fix
  • Optimization
  • Documentation Update

Description

Semaphore.withPermits(n) could permanently lose its permits when the acquiring fiber was interrupted between taking them and installing their release. The permits were then held by nobody and freed by nothing, so every later acquirer of them blocked forever, with no failure and no log.

Summary

  • take the permits and install their release in the same uninterruptible step, so no interrupt can be delivered between them
  • wait for permits inside withPermits without having them handed over, so a resumed waiter takes them itself on the retry
  • add operation sweeps interrupting an uncontended and a queued acquisition at every operation
  • add a patch changeset for effect

Root cause

Semaphore.withPermits wrapped its acquisition in restore:

uninterruptibleMask((restore) =>
  flatMap(restore(this.take(n)), (permits) =>
    onExitPrimitive(restore(self), () => release(permits), true)))

this.taken += n therefore ran inside an interruptible region, and the onExitPrimitive that installs the release was pushed by the flatMap continuation one fiber operation later. Between those two operations the count had been raised and no finalizer existed. A fiber interrupted there exited with the permits held by nobody, and nothing lowers the count afterwards: updateTakenUnsafe is only reached from a finalizer that was installed, and taken is a plain number with no ownership record, so a permit with no holder is indistinguishable from one in use.

The window is reached two ways. interruptUnsafe sets _deferredInterrupt when the fiber is running, and the run loop converts it to a failCause at the top of the next iteration. Alternatively the scheduler parks the fiber at that operation on its MaxOpsBeforeYield budget and an ordinary Fiber.interrupt arrives while it is parked.

The contended path reached the same window: a queued waiter was resumed with resume(take) and re-ran take, performing the same increment in the same restored region before the finalizer existed. #6081 moved the increment out of the observer and into that retry, which fixed a different leak but left this one, since the retry still commits inside restore.

acquireUseRelease and acquireRelease do not have this problem because they keep acquire uninterruptible and restore only the use, making the acquire-to-install span atomic with respect to interruption. withPermits could not copy that directly, because a fiber queued for permits has to stay interruptible.

withPermits now owns its acquisition instead of delegating to take. It waits on the same waiters queue, but its wait resumes with void rather than with a take, so it hands nothing over and only signals that the caller should re-check. Only that wait is restored; the increment and the onExitPrimitive both run with interruptible === false, where interruptUnsafe can only record the cause. A fiber parked at that boundary resumes, installs the finalizer, and receives the recorded cause at restore(self), so the guarded effect never runs and the permits are released. On the contended path the resumed waiter re-enters that same uninterruptible commit, so the handover window stops existing rather than being guarded.

This duplicates the parking block between take and withPermits. The two are no longer the same operation — take hands the retry over on resume and withPermits deliberately hands nothing over — so sharing them would mean parameterising the difference that is the fix. Semaphore.take is therefore untouched.

Impact

No API, type, or semantic changes, and the diff is confined to SemaphoreImpl.withPermits. Waiting for permits stays interruptible, so queues of waiters remain killable. Spurious wakeups still re-park, exactly as before, since a resumed waiter re-checks free. Semaphore.take, takeIfAvailable, release, and resize are unchanged. withPermitsIfAvailable was already correct — it commits inside the mask callback — and is unchanged.

Semaphore.take used on its own still has the window it always had: after the count is raised the fiber is interruptible again before the caller can install any release. That is inherent to a bare take and is not addressed here; withPermits is the bracketed API.

Reproduction

The leak was reproduced deterministically against main at b75884413 by sweeping the interrupt across every operation of the acquiring fiber.

Uncontended, using the stock MixedScheduler with only MaxOpsBeforeYield varied, so the fiber parks on its ordinary cooperative yield and is then interrupted by a plain Fiber.interrupt from another fiber:

maxOps 6: parked=true taken=0 next=ok
maxOps 7: parked=true taken=1 next=BLOCKED
maxOps 8: parked=true taken=0 next=ok

Contended, with the waiter queued and handed the permit by a release before being interrupted:

yield at 7: queued=true taken=0 next=ok
yield at 8: queued=true taken=1 next=BLOCKED
yield at 9: queued=true taken=0 next=ok

taken=1 with the guarded effect never having run and the acquiring fiber already dead is the leak. Neither reproduction needs a scheduler that interrupts re-entrantly; both use an ordinary yield followed by an ordinary interrupt.

Validation

  • pnpm test --run packages/effect/test/Semaphore.test.ts (20 passed)
  • pnpm test --run --project effect (244 files, 7650 passed, 3 skipped)
  • pnpm check
  • pnpm lint
  • pnpm exec changeset status --since origin/main
  • confirmed both new tests fail against the unpatched implementation by reverting only packages/effect/src/Semaphore.ts, and pass with it restored

The regression tests derive their sweep bound from the operation count of an uninterrupted acquisition rather than hard-coding it, and assert that the sweep reached the operations that hold permits, so added runtime operations widen the sweep instead of escaping it.

Related

  • No linked issue.

@github-project-automation github-project-automation Bot moved this to Discussion Ongoing in PR Backlog Aug 3, 2026
@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b47fd15

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added 4.0 bug Something isn't working labels Aug 3, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Semaphore.withPermits fix: Moved the permit-increment and finalizer-installation inside the uninterruptibleMask callback, making the acquire-to-release span atomic with respect to interruption. Only the waiter queue stays interruptible via restore(wait).
  • Regression tests: Two sweep tests that inject interruptUnsafe at every runtime operation of both an uncontended and a contended (queued) acquisition, then assert no permits are leaked and the guarded effect runs.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) (Kimi K2 not used — the program covers this model; add its provider key to run your pick) | 𝕏

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 7.06 KB 7.06 KB 0.00 KB (0.00%)
batching.ts 9.86 KB 9.86 KB 0.00 KB (0.00%)
brand.ts 6.34 KB 6.34 KB 0.00 KB (0.00%)
cache.ts 10.62 KB 10.62 KB 0.00 KB (0.00%)
config.ts 20.60 KB 20.60 KB 0.00 KB (0.00%)
differ.ts 20.20 KB 20.20 KB 0.00 KB (0.00%)
http-client.ts 21.49 KB 21.49 KB 0.00 KB (0.00%)
logger.ts 10.76 KB 10.76 KB 0.00 KB (0.00%)
metric.ts 8.98 KB 8.98 KB 0.00 KB (0.00%)
optic.ts 7.18 KB 7.18 KB 0.00 KB (0.00%)
pubsub.ts 14.90 KB 14.90 KB 0.00 KB (0.00%)
queue.ts 11.58 KB 11.58 KB 0.00 KB (0.00%)
schedule.ts 10.74 KB 10.74 KB 0.00 KB (0.00%)
schema-class.ts 19.14 KB 19.14 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 28.96 KB 28.96 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.29 KB 25.29 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.30 KB 13.30 KB 0.00 KB (0.00%)
schema-string.ts 10.94 KB 10.94 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.17 KB 15.17 KB 0.00 KB (0.00%)
schema-toArbitraryLazy.ts 21.94 KB 21.94 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.34 KB 24.34 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.18 KB 19.18 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.01 KB 19.01 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.87 KB 18.87 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.60 KB 22.60 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.52 KB 19.52 KB 0.00 KB (0.00%)
schema.ts 18.41 KB 18.41 KB 0.00 KB (0.00%)
stm.ts 12.54 KB 12.54 KB 0.00 KB (0.00%)
stream.ts 9.80 KB 9.80 KB 0.00 KB (0.00%)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Extracted waitForPermits helper: The waiter callback/observer pattern was duplicated between take and withPermits. Extracting it into a standalone waitForPermits(self, n, effect) function deduplicates the logic and avoids per-call-site closure overhead.
  • Renamed updateTakenUnsafereleaseUnsafe: The method now takes a direct n count instead of an update function, matching its sole usage pattern (this.taken -= n). Removed the dead updateTaken Effect wrapper since release and releaseAll now call releaseUnsafe directly via core.withFiber.
  • Replaced manual iterator with for...of in releaseUnsafe's waiter notification loop. The break on this.free <= 0 preserves the early-exit behavior. Set iteration handles deletion of the current element correctly per the spec.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) (Kimi K2 not used — the program covers this model; add its provider key to run your pick) | 𝕏

@tim-smart
tim-smart merged commit e02fbb6 into Effect-TS:main Aug 3, 2026
17 checks passed
@github-project-automation github-project-automation Bot moved this from Discussion Ongoing to Done in PR Backlog Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants