fix(async/unstable-semaphore): bind release in constructor (#7195) - #7218
fix(async/unstable-semaphore): bind release in constructor (#7195)#7218sanjibani wants to merge 1 commit into
Conversation
…7195) Passing `sem.release` directly to a higher-order callback such as `promise.finally(sem.release)` (or destructuring `const { release } = sem`) loses the `this` binding. `release` accesses private fields like `#head` and `#count`, so when `this` is undefined the call throws `TypeError: Cannot read properties of undefined (reading '#head')`. Bind `release` in the constructor so the method keeps its receiver regardless of how callers invoke it. The bound copy becomes an own property on the instance, so destructuring and unbound callbacks both reach a usable function.
|
|
bartlomieju
left a comment
There was a problem hiding this comment.
Thanks for picking this up — and the bug in #7195 is real, in fact worse than the issue suggests. I reproduced it locally: const { release } = sem; release() throws TypeError: Cannot read properties of undefined (reading '#head'). The issue's "sometimes works" is misleading; it never works, it just looks like it does when nothing is queued and the resulting rejection gets swallowed inside an unawaited .finally() chain.
So the problem is worth fixing. Two things need resolving before this can land.
First, CI is red, and the failing test is the one meant to demonstrate the fix — details inline.
Second, and this is a call for the team rather than something you did wrong: there is currently no .bind(this) anywhere in any @std class, so this would set a precedent. It also fixes only release, while acquire and tryAcquire touch the same private fields and still break when destructured — so a user who learns "semaphore methods are safe to pass around" from release gets bitten by the other two. I'd like a maintainer to weigh in on whether we want constructor binding applied consistently across the class, or would rather document () => sem.release() at the call site. I don't want you to do a lot of rework before that's settled.
Smaller: the PR title scope needs to be fix(async/unstable) rather than fix(async/unstable-semaphore) for the title check to pass, and the CLA still needs signing.
| const sem = new Semaphore(1); | ||
| await sem.acquire(); | ||
| await assertBlocks( | ||
| sem.acquire().finally(sem.release), |
There was a problem hiding this comment.
This is what's red-lining CI, and unfortunately the test can't validate the fix even once it stops hanging.
assertBlocks(promise, releaser) expects releaser to be the thing that unblocks promise. Here the releaser is () => {}, so nothing ever releases the permit: the second acquire() never settles, and the test dies with Promise resolution is still pending but the event loop has already resolved. I confirmed this locally against the patched source.
The deeper problem is the shape: .finally(sem.release) only runs after the acquire it is supposed to unblock has already settled, so this arrangement can never exercise the unbound-release path. The test on line 113 (can be destructured) is the one that actually proves the fix — it passes release as the releaser, which is exactly right.
Simplest resolution is to drop this test and keep the destructuring one. If you want .finally coverage specifically, it needs to be a separate permit — acquire, then somePromise.finally(sem.release), then assert a later acquire() becomes unblocked.
| this.#count = this.#max = max; | ||
| // Bind methods so they remain safe when passed unbound to higher-order | ||
| // callbacks such as `.finally(sem.release)` (issue #7195). | ||
| this.release = this.release.bind(this); |
There was a problem hiding this comment.
Two consequences of binding here that are worth being explicit about, whichever way the design question goes:
- This creates an enumerable own property, so
releasenow shows up inObject.keys(sem), object spread, and structural comparisons likeassertEquals. Two semaphores with identical state will no longer compare equal, since each holds a distinct bound function. - It shadows the prototype method, so
stub(Semaphore.prototype, "release")no longer affects instances that already exist. Instance-levelstub(sem, "release")still works, but anyone mocking via the prototype gets a silent no-op.
Neither is fatal, and binding does keep the method on the prototype (unlike an arrow class property), which is better for deno doc and JSR output. But both deserve a maintainer's eyes before we set the precedent.
| ); | ||
| } | ||
| this.#count = this.#max = max; | ||
| // Bind methods so they remain safe when passed unbound to higher-order |
There was a problem hiding this comment.
Nit: the comment says "Bind methods" (plural) but only one method is bound. That plural is a decent hint that the fix wants to cover acquire and tryAcquire too — they read the same private fields and fail identically when destructured.
Fixes #7195.
Summary
Semaphore#releasereads private fields (#head,#count,#max) andtherefore needs its receiver intact. Passing the method as a bare callback
(for example
promise.finally(sem.release)) drops thethisbinding andcrashes with
TypeError: Cannot read properties of undefined (reading '#head').Destructuring (
const { release } = sem) has the same hazard.Fix
Bind
releaseonce in the constructor. The bound copy becomes an ownproperty on the instance, so any direct member access (
sem.release,destructure, spread, higher-order callback) hands callers a function that
still resolves the private state correctly.
Tests
Added two regression tests next to the existing
unstable_semaphore_test.tssuite. Both use the original
.finally(sem.release)andconst { release }shapes from the bug report:
Semaphore.release() can be passed unbound to .finally()Semaphore.release() can be destructuredBoth tests would throw the original
TypeErroragainst the unpatched code.Notes
The module is marked
@experimental/UNSTABLE, so the API surface isfree to evolve. Binding one method does not change observable behaviour
for existing call sites that invoke
sem.release()directly — theycontinue to dispatch through the bound own property.