Skip to content

@std/async/unstable-semaphore should be bind methods in the constructor #7195

Open

Description

@SnowSquire

Describe the bug
I'm an idiot and passed .release directly into a promise chains .finally()

sema.acquire().then(() => somethingAsync()).finally(sema.release)

this means i get this error

error: Uncaught (in promise) TypeError: Cannot read properties of undefined (reading '#head')
    if (this.#head) {
             ^
    at release (https://jsr.io/@std/async/1.4.0/unstable_semaphore.ts:139:14)
    at <anonymous>

however this somehow worked(?) but inconsistently. so it should be changed, by binding in the constructor this.release = this.release.bind(this), or by fixing whatever makes it inconsistently work. I have no idea why it sometimes works. Seems to have a higher chance of working when the semaphore max is higher, but it works even when the total number of acquire/release calls is more than max so idk.

Activity

  1. truffle-dev commented on Jun 29, 2026

    @truffle-dev
    Contributor

    The throw is deterministic, not flaky. release reads this.#head on its first line, and .finally(sema.release) invokes it with this === undefined, so it always throws TypeError: Cannot read properties of undefined (reading '#head').

    What varies is the consequence, which is what made it look inconsistent:

    • The rejection is unobserved (no .catch), so whether you actually see "Uncaught (in promise)" depends on whether the runtime surfaces the unhandled rejection before the program exits.
    • Every detached release throws before restoring the permit, so the permit leaks. Starting from max, each leaked permit shrinks the live pool, and a later acquire only blocks once cumulative leaks exhaust the headroom — so a higher max masks it longer, exactly as you saw.

    Permits never recover:

    const s = new Semaphore(2);
    await s.acquire().then(() => {}).finally(s.release).catch(() => {});
    await s.acquire().then(() => {}).finally(s.release).catch(() => {});
    // both permits leaked; the next acquire() blocks forever

    On the fix: binding in the constructor works, but there's no *.bind(this) in a constructor anywhere in std today. An arrow class field (release = (): void => { ... }) auto-binds, or release stays a prototype method and the doc notes it must be called on the instance. Since this is a concurrency primitive the per-instance allocation tradeoff is a real one. Happy to send a PR for whichever shape you'd prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions