Skip to content

fix: dispose call args on all failure paths per new StubHook ownership contract - #241

Merged
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal
Aug 17, 2026
Merged

fix: dispose call args on all failure paths per new StubHook ownership contract#241
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

RPC call arguments (and map captures) leaked whenever a call failed before reaching a callee. A rejected pipeline promise, a broken or disposed stub, a failed argument serialization, or a sync throw inside a hook.

Practical example:

using api=newWebSocketRpcSession<Api>("wss://example.com/api");letsession=api.authenticate(token);// NOT awaited — returns a promise stub
using uploader=session.getUploader();// pipelined on the unresolved promiseuploader.write(chunk1,progressCallback);// pipelined further, passes a capabilityuploader.write(chunk2,progressCallback);

lets say token is expired so authenticate() rejects. Every call pipelined behind it had already deep-copied its arguments, including a dup of progressCallback's hook. Before this PR, those copies were simply dropped on the rejection path: progressCallback's disposer never runs, its entry stays pinned.

Rather than patching each site with caller-side try/catch (as an earlier revision of this PR did), the fix defines the rule once on the abstract StubHook: call(), stream(), and map() take ownership of their args/captures even when they throw synchronously, and callers never dispose after invoking.

The implementations that violated this are fixed

  • RpcImportHook.call()/stream() (including mid-serialization failures, where disposal is safe because exported hooks are dups and the Devaluator rolls back its exports)
  • -the default stream() (which leaked the result hook when pull() threw)
  • MapVariableHook
  • the map placeholder

PromiseStubHook now disposes its copied args only when its backing promise rejects i.e. where no callee ever existed to take them.

Also kept from the original PR: PromiseStubHook.dispose() chains on the backing promise so disposal stays ordered behind already-queued calls.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e7bea9

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

This PR includes changesets to release 1 package
NameType
capnwebPatch

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

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@241

commit: 7e7bea9

@ask-bonk

This comment was marked as outdated.

@ndisidore

ndisidore commented Aug 12, 2026

Copy link
Copy Markdown
MemberAuthor

One semantics question I hit while in here: if a call's args contain unresolved promises, deliverCall() waits on them before invoking the target, so a dispose() right after the call can win the race and the method runs on a disposed target.

Pre-existing stuff: this PR just keeps disposal from overtaking forwarding, same as calling the destination hook directly. Is that intended, or should disposal also wait for in-flight deliveries? (That'd be a deliverCall/TargetStubHook lifetime change, so I didn't touch it.)

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 77ff252 to 1cd41dcCompareAugust 12, 2026 20:50
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 20:51
@ask-bonk

This comment was marked as outdated.

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 1cd41dc to ed803feCompareAugust 12, 2026 21:35
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
ndisidoreand others added 4 commits August 13, 2026 20:28
…ind queued calls
PromiseStubHook.call() and .stream() deep-copy their arguments before
chaining on the backing promise, but if that promise rejects, the copies
were never disposed, leaking any stubs they contained.
PromiseStubHook.dispose() also had a fast path that disposed the
resolution synchronously once available. A call chained on the promise
just before disposal could then be delivered after its target was
already disposed, violating ordering. Disposal now always chains on the
promise so it stays behind previously queued calls.
ErrorStubHook.call() ignored the arguments it takes ownership of, and
ErrorStubHook.map() likewise ignored its captures, so anything forwarded
to a broken or disposed hook leaked. ValueStubHook.call() had the same
gap on its error path (its own map() already disposes captures there),
and PromiseStubHook's forwarding continuations did not clean up when the
destination hook threw synchronously.
Co-authored-by: Kenton Varda <kenton@cloudflare.com>
… args
Document on the abstract StubHook that call(), stream(), and map() take
ownership of their args/captures even on synchronous throw, and fix the
implementations that violated it:
- RpcImportHook.call()/stream(): dispose args if getEntry() throws, and in
sendCall()/sendStream() when aborted or when argument serialization fails
(safe: exported hooks are dups and the Devaluator rolls back its exports).
- Default StubHook.stream(): dispose the result hook if pull() throws.
- MapVariableHook and the map-not-loaded placeholder: dispose args/captures
before throwing.
- ValueStubHook.call(): restructure to the inner-catch pattern so delegation
clearly hands ownership to the delegate.
- PromiseStubHook: drop the success-path try/catch around hook.call()/
hook.stream() -- per the contract the resolved callee owns the args; keep
disposal only on rejection, where no callee ever existed.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from b9f79da to c93a265CompareAugust 14, 2026 02:02
- RpcImportHook: collapse the three hand-rolled getEntry() guards in
call()/stream()/map() into a private getEntryTakingOwnership() helper.
- ValueStubHook.map(): flatten the nested try/catch into a single catch that
disposes captures defensively (dispose() is idempotent), matching call()'s
flat shape.
- Tests: SyncThrowingHook extends ErrorStubHook instead of hand-stubbing all
eight abstract StubHook methods.
- Condense the changeset to changelog style.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from c93a265 to a8be070CompareAugust 14, 2026 02:06
@ndisidorendisidore changed the title fix: PromiseStubHook leaks call args on rejection and can dispose out of orderfix: dispose call args on all failure paths per new StubHook ownership contractAug 14, 2026
Comment threadsrc/core.ts Outdated
Comment thread.changeset/promise-stubhook-disposal.md Outdated
Comment threadsrc/core.ts
- Revert the mapImpl placeholder change: the stubs are always replaced at
startup, so handling disposal there is dead code.
- ValueStubHook.map(): restore the narrow inner catch. Once ownership of the
captures transfers to the delegate or applyMap(), disposing them in the
outer catch is incorrect (a partially-completed callee may hold live
references, e.g. hooks stored in the export table).
- Changeset: drop implementation details from the changelog entry.
@ndisidore
ndisidore merged commit 2de5871 into mainAug 17, 2026
9 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 17, 2026
ndisidore added a commit that referenced this pull request Aug 20, 2026
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
ndisidore added a commit that referenced this pull request Aug 20, 2026
* feat: construct RpcPromise from a Promise
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
* fix: address review feedback on RpcPromise promise construction
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
* fix: address code-review findings on RpcPromise-from-Promise
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
* docs: remove constructor special-case paragraphs per review
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
* fix: repair dup(), onRpcBroken, and property adoption for promise-wrapped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
* fix: let rejection reject the backing promise instead of adopting ErrorStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
dimitropoulos added a commit that referenced this pull request Aug 21, 2026
0.12.0 added a way to construct an `RpcPromise` from an ordinary `Promise`, so
callers can pipeline against a capability you have not obtained yet. That is
new public API, not a bug fix, and the docs said nothing about it -- main
documented it in the root README, which this branch replaced with a pointer to
here, so the merge would otherwise have dropped it on the floor.
`concepts/promises.md` gets a section, and it leans on the equivalence the API
docs make: wrapping a promise means the same thing as a local-loopback call
returning it. That is worth stating plainly, because it derives all the rules
that would otherwise have to be listed as a second set to memorise -- the
resolution is serialized, targets and functions come back as stubs, ownership
transfers, rejections propagate. The ownership one is the sharp edge and is
called out: disposing the promise disposes the resolution, so resolve with a
`.dup()` if you want to keep a stub.
The cheat sheet gets a one-line constructor note, matching the one `RpcStub`
already has.
Also: a Changelog entry in the sidebar under Reference, pointing at the GitHub
releases page. Off-site on purpose -- release notes are generated from
changesets on every publish, so a page here would be a copy that goes stale the
next time anyone ships. Nimbus recognises the absolute URL and adds
`target="_blank" rel="noopener"` itself.
Nothing else in the merge needed documenting. #241, #243, #251 and #253 are
leak and typing fixes with no API surface to describe; #253's note that
`RpcPromise<RpcStub<T>>` should now be written `RpcPromise<T>` applies to no
annotation anywhere in these pages.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ndisidore@kentonv
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: dispose call args on all failure paths per new StubHook ownership contract by ndisidore · Pull Request #241 · cloudflare/capnweb · GitHub
Skip to content

fix: dispose call args on all failure paths per new StubHook ownership contract - #241

Merged
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal
Aug 17, 2026
Merged

fix: dispose call args on all failure paths per new StubHook ownership contract#241
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

RPC call arguments (and map captures) leaked whenever a call failed before reaching a callee. A rejected pipeline promise, a broken or disposed stub, a failed argument serialization, or a sync throw inside a hook.

Practical example:

using api=newWebSocketRpcSession<Api>("wss://example.com/api");letsession=api.authenticate(token);// NOT awaited — returns a promise stub
using uploader=session.getUploader();// pipelined on the unresolved promiseuploader.write(chunk1,progressCallback);// pipelined further, passes a capabilityuploader.write(chunk2,progressCallback);

lets say token is expired so authenticate() rejects. Every call pipelined behind it had already deep-copied its arguments, including a dup of progressCallback's hook. Before this PR, those copies were simply dropped on the rejection path: progressCallback's disposer never runs, its entry stays pinned.

Rather than patching each site with caller-side try/catch (as an earlier revision of this PR did), the fix defines the rule once on the abstract StubHook: call(), stream(), and map() take ownership of their args/captures even when they throw synchronously, and callers never dispose after invoking.

The implementations that violated this are fixed

  • RpcImportHook.call()/stream() (including mid-serialization failures, where disposal is safe because exported hooks are dups and the Devaluator rolls back its exports)
  • -the default stream() (which leaked the result hook when pull() threw)
  • MapVariableHook
  • the map placeholder

PromiseStubHook now disposes its copied args only when its backing promise rejects i.e. where no callee ever existed to take them.

Also kept from the original PR: PromiseStubHook.dispose() chains on the backing promise so disposal stays ordered behind already-queued calls.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e7bea9

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

This PR includes changesets to release 1 package
NameType
capnwebPatch

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

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@241

commit: 7e7bea9

@ask-bonk

This comment was marked as outdated.

@ndisidore

ndisidore commented Aug 12, 2026

Copy link
Copy Markdown
MemberAuthor

One semantics question I hit while in here: if a call's args contain unresolved promises, deliverCall() waits on them before invoking the target, so a dispose() right after the call can win the race and the method runs on a disposed target.

Pre-existing stuff: this PR just keeps disposal from overtaking forwarding, same as calling the destination hook directly. Is that intended, or should disposal also wait for in-flight deliveries? (That'd be a deliverCall/TargetStubHook lifetime change, so I didn't touch it.)

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 77ff252 to 1cd41dcCompareAugust 12, 2026 20:50
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 20:51
@ask-bonk

This comment was marked as outdated.

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 1cd41dc to ed803feCompareAugust 12, 2026 21:35
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
ndisidoreand others added 4 commits August 13, 2026 20:28
…ind queued calls
PromiseStubHook.call() and .stream() deep-copy their arguments before
chaining on the backing promise, but if that promise rejects, the copies
were never disposed, leaking any stubs they contained.
PromiseStubHook.dispose() also had a fast path that disposed the
resolution synchronously once available. A call chained on the promise
just before disposal could then be delivered after its target was
already disposed, violating ordering. Disposal now always chains on the
promise so it stays behind previously queued calls.
ErrorStubHook.call() ignored the arguments it takes ownership of, and
ErrorStubHook.map() likewise ignored its captures, so anything forwarded
to a broken or disposed hook leaked. ValueStubHook.call() had the same
gap on its error path (its own map() already disposes captures there),
and PromiseStubHook's forwarding continuations did not clean up when the
destination hook threw synchronously.
Co-authored-by: Kenton Varda <kenton@cloudflare.com>
… args
Document on the abstract StubHook that call(), stream(), and map() take
ownership of their args/captures even on synchronous throw, and fix the
implementations that violated it:
- RpcImportHook.call()/stream(): dispose args if getEntry() throws, and in
sendCall()/sendStream() when aborted or when argument serialization fails
(safe: exported hooks are dups and the Devaluator rolls back its exports).
- Default StubHook.stream(): dispose the result hook if pull() throws.
- MapVariableHook and the map-not-loaded placeholder: dispose args/captures
before throwing.
- ValueStubHook.call(): restructure to the inner-catch pattern so delegation
clearly hands ownership to the delegate.
- PromiseStubHook: drop the success-path try/catch around hook.call()/
hook.stream() -- per the contract the resolved callee owns the args; keep
disposal only on rejection, where no callee ever existed.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from b9f79da to c93a265CompareAugust 14, 2026 02:02
- RpcImportHook: collapse the three hand-rolled getEntry() guards in
call()/stream()/map() into a private getEntryTakingOwnership() helper.
- ValueStubHook.map(): flatten the nested try/catch into a single catch that
disposes captures defensively (dispose() is idempotent), matching call()'s
flat shape.
- Tests: SyncThrowingHook extends ErrorStubHook instead of hand-stubbing all
eight abstract StubHook methods.
- Condense the changeset to changelog style.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from c93a265 to a8be070CompareAugust 14, 2026 02:06
@ndisidorendisidore changed the title fix: PromiseStubHook leaks call args on rejection and can dispose out of orderfix: dispose call args on all failure paths per new StubHook ownership contractAug 14, 2026
Comment threadsrc/core.ts Outdated
Comment thread.changeset/promise-stubhook-disposal.md Outdated
Comment threadsrc/core.ts
- Revert the mapImpl placeholder change: the stubs are always replaced at
startup, so handling disposal there is dead code.
- ValueStubHook.map(): restore the narrow inner catch. Once ownership of the
captures transfers to the delegate or applyMap(), disposing them in the
outer catch is incorrect (a partially-completed callee may hold live
references, e.g. hooks stored in the export table).
- Changeset: drop implementation details from the changelog entry.
@ndisidore
ndisidore merged commit 2de5871 into mainAug 17, 2026
9 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 17, 2026
ndisidore added a commit that referenced this pull request Aug 20, 2026
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
ndisidore added a commit that referenced this pull request Aug 20, 2026
* feat: construct RpcPromise from a Promise
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
* fix: address review feedback on RpcPromise promise construction
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
* fix: address code-review findings on RpcPromise-from-Promise
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
* docs: remove constructor special-case paragraphs per review
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
* fix: repair dup(), onRpcBroken, and property adoption for promise-wrapped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
* fix: let rejection reject the backing promise instead of adopting ErrorStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
dimitropoulos added a commit that referenced this pull request Aug 21, 2026
0.12.0 added a way to construct an `RpcPromise` from an ordinary `Promise`, so
callers can pipeline against a capability you have not obtained yet. That is
new public API, not a bug fix, and the docs said nothing about it -- main
documented it in the root README, which this branch replaced with a pointer to
here, so the merge would otherwise have dropped it on the floor.
`concepts/promises.md` gets a section, and it leans on the equivalence the API
docs make: wrapping a promise means the same thing as a local-loopback call
returning it. That is worth stating plainly, because it derives all the rules
that would otherwise have to be listed as a second set to memorise -- the
resolution is serialized, targets and functions come back as stubs, ownership
transfers, rejections propagate. The ownership one is the sharp edge and is
called out: disposing the promise disposes the resolution, so resolve with a
`.dup()` if you want to keep a stub.
The cheat sheet gets a one-line constructor note, matching the one `RpcStub`
already has.
Also: a Changelog entry in the sidebar under Reference, pointing at the GitHub
releases page. Off-site on purpose -- release notes are generated from
changesets on every publish, so a page here would be a copy that goes stale the
next time anyone ships. Nimbus recognises the absolute URL and adds
`target="_blank" rel="noopener"` itself.
Nothing else in the merge needed documenting. #241, #243, #251 and #253 are
leak and typing fixes with no API surface to describe; #253's note that
`RpcPromise<RpcStub<T>>` should now be written `RpcPromise<T>` applies to no
annotation anywhere in these pages.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ndisidore@kentonv
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: dispose call args on all failure paths per new StubHook ownership contract by ndisidore · Pull Request #241 · cloudflare/capnweb · GitHub
Skip to content

fix: dispose call args on all failure paths per new StubHook ownership contract - #241

Merged
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal
Aug 17, 2026
Merged

fix: dispose call args on all failure paths per new StubHook ownership contract#241
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

RPC call arguments (and map captures) leaked whenever a call failed before reaching a callee. A rejected pipeline promise, a broken or disposed stub, a failed argument serialization, or a sync throw inside a hook.

Practical example:

using api=newWebSocketRpcSession<Api>("wss://example.com/api");letsession=api.authenticate(token);// NOT awaited — returns a promise stub
using uploader=session.getUploader();// pipelined on the unresolved promiseuploader.write(chunk1,progressCallback);// pipelined further, passes a capabilityuploader.write(chunk2,progressCallback);

lets say token is expired so authenticate() rejects. Every call pipelined behind it had already deep-copied its arguments, including a dup of progressCallback's hook. Before this PR, those copies were simply dropped on the rejection path: progressCallback's disposer never runs, its entry stays pinned.

Rather than patching each site with caller-side try/catch (as an earlier revision of this PR did), the fix defines the rule once on the abstract StubHook: call(), stream(), and map() take ownership of their args/captures even when they throw synchronously, and callers never dispose after invoking.

The implementations that violated this are fixed

  • RpcImportHook.call()/stream() (including mid-serialization failures, where disposal is safe because exported hooks are dups and the Devaluator rolls back its exports)
  • -the default stream() (which leaked the result hook when pull() threw)
  • MapVariableHook
  • the map placeholder

PromiseStubHook now disposes its copied args only when its backing promise rejects i.e. where no callee ever existed to take them.

Also kept from the original PR: PromiseStubHook.dispose() chains on the backing promise so disposal stays ordered behind already-queued calls.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e7bea9

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

This PR includes changesets to release 1 package
NameType
capnwebPatch

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

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@241

commit: 7e7bea9

@ask-bonk

This comment was marked as outdated.

@ndisidore

ndisidore commented Aug 12, 2026

Copy link
Copy Markdown
MemberAuthor

One semantics question I hit while in here: if a call's args contain unresolved promises, deliverCall() waits on them before invoking the target, so a dispose() right after the call can win the race and the method runs on a disposed target.

Pre-existing stuff: this PR just keeps disposal from overtaking forwarding, same as calling the destination hook directly. Is that intended, or should disposal also wait for in-flight deliveries? (That'd be a deliverCall/TargetStubHook lifetime change, so I didn't touch it.)

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 77ff252 to 1cd41dcCompareAugust 12, 2026 20:50
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 20:51
@ask-bonk

This comment was marked as outdated.

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 1cd41dc to ed803feCompareAugust 12, 2026 21:35
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
ndisidoreand others added 4 commits August 13, 2026 20:28
…ind queued calls
PromiseStubHook.call() and .stream() deep-copy their arguments before
chaining on the backing promise, but if that promise rejects, the copies
were never disposed, leaking any stubs they contained.
PromiseStubHook.dispose() also had a fast path that disposed the
resolution synchronously once available. A call chained on the promise
just before disposal could then be delivered after its target was
already disposed, violating ordering. Disposal now always chains on the
promise so it stays behind previously queued calls.
ErrorStubHook.call() ignored the arguments it takes ownership of, and
ErrorStubHook.map() likewise ignored its captures, so anything forwarded
to a broken or disposed hook leaked. ValueStubHook.call() had the same
gap on its error path (its own map() already disposes captures there),
and PromiseStubHook's forwarding continuations did not clean up when the
destination hook threw synchronously.
Co-authored-by: Kenton Varda <kenton@cloudflare.com>
… args
Document on the abstract StubHook that call(), stream(), and map() take
ownership of their args/captures even on synchronous throw, and fix the
implementations that violated it:
- RpcImportHook.call()/stream(): dispose args if getEntry() throws, and in
sendCall()/sendStream() when aborted or when argument serialization fails
(safe: exported hooks are dups and the Devaluator rolls back its exports).
- Default StubHook.stream(): dispose the result hook if pull() throws.
- MapVariableHook and the map-not-loaded placeholder: dispose args/captures
before throwing.
- ValueStubHook.call(): restructure to the inner-catch pattern so delegation
clearly hands ownership to the delegate.
- PromiseStubHook: drop the success-path try/catch around hook.call()/
hook.stream() -- per the contract the resolved callee owns the args; keep
disposal only on rejection, where no callee ever existed.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from b9f79da to c93a265CompareAugust 14, 2026 02:02
- RpcImportHook: collapse the three hand-rolled getEntry() guards in
call()/stream()/map() into a private getEntryTakingOwnership() helper.
- ValueStubHook.map(): flatten the nested try/catch into a single catch that
disposes captures defensively (dispose() is idempotent), matching call()'s
flat shape.
- Tests: SyncThrowingHook extends ErrorStubHook instead of hand-stubbing all
eight abstract StubHook methods.
- Condense the changeset to changelog style.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from c93a265 to a8be070CompareAugust 14, 2026 02:06
@ndisidorendisidore changed the title fix: PromiseStubHook leaks call args on rejection and can dispose out of orderfix: dispose call args on all failure paths per new StubHook ownership contractAug 14, 2026
Comment threadsrc/core.ts Outdated
Comment thread.changeset/promise-stubhook-disposal.md Outdated
Comment threadsrc/core.ts
- Revert the mapImpl placeholder change: the stubs are always replaced at
startup, so handling disposal there is dead code.
- ValueStubHook.map(): restore the narrow inner catch. Once ownership of the
captures transfers to the delegate or applyMap(), disposing them in the
outer catch is incorrect (a partially-completed callee may hold live
references, e.g. hooks stored in the export table).
- Changeset: drop implementation details from the changelog entry.
@ndisidore
ndisidore merged commit 2de5871 into mainAug 17, 2026
9 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 17, 2026
ndisidore added a commit that referenced this pull request Aug 20, 2026
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
ndisidore added a commit that referenced this pull request Aug 20, 2026
* feat: construct RpcPromise from a Promise
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
* fix: address review feedback on RpcPromise promise construction
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
* fix: address code-review findings on RpcPromise-from-Promise
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
* docs: remove constructor special-case paragraphs per review
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
* fix: repair dup(), onRpcBroken, and property adoption for promise-wrapped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
* fix: let rejection reject the backing promise instead of adopting ErrorStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
dimitropoulos added a commit that referenced this pull request Aug 21, 2026
0.12.0 added a way to construct an `RpcPromise` from an ordinary `Promise`, so
callers can pipeline against a capability you have not obtained yet. That is
new public API, not a bug fix, and the docs said nothing about it -- main
documented it in the root README, which this branch replaced with a pointer to
here, so the merge would otherwise have dropped it on the floor.
`concepts/promises.md` gets a section, and it leans on the equivalence the API
docs make: wrapping a promise means the same thing as a local-loopback call
returning it. That is worth stating plainly, because it derives all the rules
that would otherwise have to be listed as a second set to memorise -- the
resolution is serialized, targets and functions come back as stubs, ownership
transfers, rejections propagate. The ownership one is the sharp edge and is
called out: disposing the promise disposes the resolution, so resolve with a
`.dup()` if you want to keep a stub.
The cheat sheet gets a one-line constructor note, matching the one `RpcStub`
already has.
Also: a Changelog entry in the sidebar under Reference, pointing at the GitHub
releases page. Off-site on purpose -- release notes are generated from
changesets on every publish, so a page here would be a copy that goes stale the
next time anyone ships. Nimbus recognises the absolute URL and adds
`target="_blank" rel="noopener"` itself.
Nothing else in the merge needed documenting. #241, #243, #251 and #253 are
leak and typing fixes with no API surface to describe; #253's note that
`RpcPromise<RpcStub<T>>` should now be written `RpcPromise<T>` applies to no
annotation anywhere in these pages.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ndisidore@kentonv
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: dispose call args on all failure paths per new StubHook ownership contract by ndisidore · Pull Request #241 · cloudflare/capnweb · GitHub
Skip to content

fix: dispose call args on all failure paths per new StubHook ownership contract - #241

Merged
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal
Aug 17, 2026
Merged

fix: dispose call args on all failure paths per new StubHook ownership contract#241
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

RPC call arguments (and map captures) leaked whenever a call failed before reaching a callee. A rejected pipeline promise, a broken or disposed stub, a failed argument serialization, or a sync throw inside a hook.

Practical example:

using api=newWebSocketRpcSession<Api>("wss://example.com/api");letsession=api.authenticate(token);// NOT awaited — returns a promise stub
using uploader=session.getUploader();// pipelined on the unresolved promiseuploader.write(chunk1,progressCallback);// pipelined further, passes a capabilityuploader.write(chunk2,progressCallback);

lets say token is expired so authenticate() rejects. Every call pipelined behind it had already deep-copied its arguments, including a dup of progressCallback's hook. Before this PR, those copies were simply dropped on the rejection path: progressCallback's disposer never runs, its entry stays pinned.

Rather than patching each site with caller-side try/catch (as an earlier revision of this PR did), the fix defines the rule once on the abstract StubHook: call(), stream(), and map() take ownership of their args/captures even when they throw synchronously, and callers never dispose after invoking.

The implementations that violated this are fixed

  • RpcImportHook.call()/stream() (including mid-serialization failures, where disposal is safe because exported hooks are dups and the Devaluator rolls back its exports)
  • -the default stream() (which leaked the result hook when pull() threw)
  • MapVariableHook
  • the map placeholder

PromiseStubHook now disposes its copied args only when its backing promise rejects i.e. where no callee ever existed to take them.

Also kept from the original PR: PromiseStubHook.dispose() chains on the backing promise so disposal stays ordered behind already-queued calls.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e7bea9

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

This PR includes changesets to release 1 package
NameType
capnwebPatch

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

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@241

commit: 7e7bea9

@ask-bonk

This comment was marked as outdated.

@ndisidore

ndisidore commented Aug 12, 2026

Copy link
Copy Markdown
MemberAuthor

One semantics question I hit while in here: if a call's args contain unresolved promises, deliverCall() waits on them before invoking the target, so a dispose() right after the call can win the race and the method runs on a disposed target.

Pre-existing stuff: this PR just keeps disposal from overtaking forwarding, same as calling the destination hook directly. Is that intended, or should disposal also wait for in-flight deliveries? (That'd be a deliverCall/TargetStubHook lifetime change, so I didn't touch it.)

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 77ff252 to 1cd41dcCompareAugust 12, 2026 20:50
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 20:51
@ask-bonk

This comment was marked as outdated.

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 1cd41dc to ed803feCompareAugust 12, 2026 21:35
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
ndisidoreand others added 4 commits August 13, 2026 20:28
…ind queued calls
PromiseStubHook.call() and .stream() deep-copy their arguments before
chaining on the backing promise, but if that promise rejects, the copies
were never disposed, leaking any stubs they contained.
PromiseStubHook.dispose() also had a fast path that disposed the
resolution synchronously once available. A call chained on the promise
just before disposal could then be delivered after its target was
already disposed, violating ordering. Disposal now always chains on the
promise so it stays behind previously queued calls.
ErrorStubHook.call() ignored the arguments it takes ownership of, and
ErrorStubHook.map() likewise ignored its captures, so anything forwarded
to a broken or disposed hook leaked. ValueStubHook.call() had the same
gap on its error path (its own map() already disposes captures there),
and PromiseStubHook's forwarding continuations did not clean up when the
destination hook threw synchronously.
Co-authored-by: Kenton Varda <kenton@cloudflare.com>
… args
Document on the abstract StubHook that call(), stream(), and map() take
ownership of their args/captures even on synchronous throw, and fix the
implementations that violated it:
- RpcImportHook.call()/stream(): dispose args if getEntry() throws, and in
sendCall()/sendStream() when aborted or when argument serialization fails
(safe: exported hooks are dups and the Devaluator rolls back its exports).
- Default StubHook.stream(): dispose the result hook if pull() throws.
- MapVariableHook and the map-not-loaded placeholder: dispose args/captures
before throwing.
- ValueStubHook.call(): restructure to the inner-catch pattern so delegation
clearly hands ownership to the delegate.
- PromiseStubHook: drop the success-path try/catch around hook.call()/
hook.stream() -- per the contract the resolved callee owns the args; keep
disposal only on rejection, where no callee ever existed.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from b9f79da to c93a265CompareAugust 14, 2026 02:02
- RpcImportHook: collapse the three hand-rolled getEntry() guards in
call()/stream()/map() into a private getEntryTakingOwnership() helper.
- ValueStubHook.map(): flatten the nested try/catch into a single catch that
disposes captures defensively (dispose() is idempotent), matching call()'s
flat shape.
- Tests: SyncThrowingHook extends ErrorStubHook instead of hand-stubbing all
eight abstract StubHook methods.
- Condense the changeset to changelog style.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from c93a265 to a8be070CompareAugust 14, 2026 02:06
@ndisidorendisidore changed the title fix: PromiseStubHook leaks call args on rejection and can dispose out of orderfix: dispose call args on all failure paths per new StubHook ownership contractAug 14, 2026
Comment threadsrc/core.ts Outdated
Comment thread.changeset/promise-stubhook-disposal.md Outdated
Comment threadsrc/core.ts
- Revert the mapImpl placeholder change: the stubs are always replaced at
startup, so handling disposal there is dead code.
- ValueStubHook.map(): restore the narrow inner catch. Once ownership of the
captures transfers to the delegate or applyMap(), disposing them in the
outer catch is incorrect (a partially-completed callee may hold live
references, e.g. hooks stored in the export table).
- Changeset: drop implementation details from the changelog entry.
@ndisidore
ndisidore merged commit 2de5871 into mainAug 17, 2026
9 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 17, 2026
ndisidore added a commit that referenced this pull request Aug 20, 2026
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
ndisidore added a commit that referenced this pull request Aug 20, 2026
* feat: construct RpcPromise from a Promise
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
* fix: address review feedback on RpcPromise promise construction
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
* fix: address code-review findings on RpcPromise-from-Promise
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
* docs: remove constructor special-case paragraphs per review
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
* fix: repair dup(), onRpcBroken, and property adoption for promise-wrapped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
* fix: let rejection reject the backing promise instead of adopting ErrorStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
dimitropoulos added a commit that referenced this pull request Aug 21, 2026
0.12.0 added a way to construct an `RpcPromise` from an ordinary `Promise`, so
callers can pipeline against a capability you have not obtained yet. That is
new public API, not a bug fix, and the docs said nothing about it -- main
documented it in the root README, which this branch replaced with a pointer to
here, so the merge would otherwise have dropped it on the floor.
`concepts/promises.md` gets a section, and it leans on the equivalence the API
docs make: wrapping a promise means the same thing as a local-loopback call
returning it. That is worth stating plainly, because it derives all the rules
that would otherwise have to be listed as a second set to memorise -- the
resolution is serialized, targets and functions come back as stubs, ownership
transfers, rejections propagate. The ownership one is the sharp edge and is
called out: disposing the promise disposes the resolution, so resolve with a
`.dup()` if you want to keep a stub.
The cheat sheet gets a one-line constructor note, matching the one `RpcStub`
already has.
Also: a Changelog entry in the sidebar under Reference, pointing at the GitHub
releases page. Off-site on purpose -- release notes are generated from
changesets on every publish, so a page here would be a copy that goes stale the
next time anyone ships. Nimbus recognises the absolute URL and adds
`target="_blank" rel="noopener"` itself.
Nothing else in the merge needed documenting. #241, #243, #251 and #253 are
leak and typing fixes with no API surface to describe; #253's note that
`RpcPromise<RpcStub<T>>` should now be written `RpcPromise<T>` applies to no
annotation anywhere in these pages.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ndisidore@kentonv
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: dispose call args on all failure paths per new StubHook ownership contract by ndisidore · Pull Request #241 · cloudflare/capnweb · GitHub
Skip to content

fix: dispose call args on all failure paths per new StubHook ownership contract - #241

Merged
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal
Aug 17, 2026
Merged

fix: dispose call args on all failure paths per new StubHook ownership contract#241
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

RPC call arguments (and map captures) leaked whenever a call failed before reaching a callee. A rejected pipeline promise, a broken or disposed stub, a failed argument serialization, or a sync throw inside a hook.

Practical example:

using api=newWebSocketRpcSession<Api>("wss://example.com/api");letsession=api.authenticate(token);// NOT awaited — returns a promise stub
using uploader=session.getUploader();// pipelined on the unresolved promiseuploader.write(chunk1,progressCallback);// pipelined further, passes a capabilityuploader.write(chunk2,progressCallback);

lets say token is expired so authenticate() rejects. Every call pipelined behind it had already deep-copied its arguments, including a dup of progressCallback's hook. Before this PR, those copies were simply dropped on the rejection path: progressCallback's disposer never runs, its entry stays pinned.

Rather than patching each site with caller-side try/catch (as an earlier revision of this PR did), the fix defines the rule once on the abstract StubHook: call(), stream(), and map() take ownership of their args/captures even when they throw synchronously, and callers never dispose after invoking.

The implementations that violated this are fixed

  • RpcImportHook.call()/stream() (including mid-serialization failures, where disposal is safe because exported hooks are dups and the Devaluator rolls back its exports)
  • -the default stream() (which leaked the result hook when pull() threw)
  • MapVariableHook
  • the map placeholder

PromiseStubHook now disposes its copied args only when its backing promise rejects i.e. where no callee ever existed to take them.

Also kept from the original PR: PromiseStubHook.dispose() chains on the backing promise so disposal stays ordered behind already-queued calls.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e7bea9

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

This PR includes changesets to release 1 package
NameType
capnwebPatch

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

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@241

commit: 7e7bea9

@ask-bonk

This comment was marked as outdated.

@ndisidore

ndisidore commented Aug 12, 2026

Copy link
Copy Markdown
MemberAuthor

One semantics question I hit while in here: if a call's args contain unresolved promises, deliverCall() waits on them before invoking the target, so a dispose() right after the call can win the race and the method runs on a disposed target.

Pre-existing stuff: this PR just keeps disposal from overtaking forwarding, same as calling the destination hook directly. Is that intended, or should disposal also wait for in-flight deliveries? (That'd be a deliverCall/TargetStubHook lifetime change, so I didn't touch it.)

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 77ff252 to 1cd41dcCompareAugust 12, 2026 20:50
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 20:51
@ask-bonk

This comment was marked as outdated.

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 1cd41dc to ed803feCompareAugust 12, 2026 21:35
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
ndisidoreand others added 4 commits August 13, 2026 20:28
…ind queued calls
PromiseStubHook.call() and .stream() deep-copy their arguments before
chaining on the backing promise, but if that promise rejects, the copies
were never disposed, leaking any stubs they contained.
PromiseStubHook.dispose() also had a fast path that disposed the
resolution synchronously once available. A call chained on the promise
just before disposal could then be delivered after its target was
already disposed, violating ordering. Disposal now always chains on the
promise so it stays behind previously queued calls.
ErrorStubHook.call() ignored the arguments it takes ownership of, and
ErrorStubHook.map() likewise ignored its captures, so anything forwarded
to a broken or disposed hook leaked. ValueStubHook.call() had the same
gap on its error path (its own map() already disposes captures there),
and PromiseStubHook's forwarding continuations did not clean up when the
destination hook threw synchronously.
Co-authored-by: Kenton Varda <kenton@cloudflare.com>
… args
Document on the abstract StubHook that call(), stream(), and map() take
ownership of their args/captures even on synchronous throw, and fix the
implementations that violated it:
- RpcImportHook.call()/stream(): dispose args if getEntry() throws, and in
sendCall()/sendStream() when aborted or when argument serialization fails
(safe: exported hooks are dups and the Devaluator rolls back its exports).
- Default StubHook.stream(): dispose the result hook if pull() throws.
- MapVariableHook and the map-not-loaded placeholder: dispose args/captures
before throwing.
- ValueStubHook.call(): restructure to the inner-catch pattern so delegation
clearly hands ownership to the delegate.
- PromiseStubHook: drop the success-path try/catch around hook.call()/
hook.stream() -- per the contract the resolved callee owns the args; keep
disposal only on rejection, where no callee ever existed.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from b9f79da to c93a265CompareAugust 14, 2026 02:02
- RpcImportHook: collapse the three hand-rolled getEntry() guards in
call()/stream()/map() into a private getEntryTakingOwnership() helper.
- ValueStubHook.map(): flatten the nested try/catch into a single catch that
disposes captures defensively (dispose() is idempotent), matching call()'s
flat shape.
- Tests: SyncThrowingHook extends ErrorStubHook instead of hand-stubbing all
eight abstract StubHook methods.
- Condense the changeset to changelog style.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from c93a265 to a8be070CompareAugust 14, 2026 02:06
@ndisidorendisidore changed the title fix: PromiseStubHook leaks call args on rejection and can dispose out of orderfix: dispose call args on all failure paths per new StubHook ownership contractAug 14, 2026
Comment threadsrc/core.ts Outdated
Comment thread.changeset/promise-stubhook-disposal.md Outdated
Comment threadsrc/core.ts
- Revert the mapImpl placeholder change: the stubs are always replaced at
startup, so handling disposal there is dead code.
- ValueStubHook.map(): restore the narrow inner catch. Once ownership of the
captures transfers to the delegate or applyMap(), disposing them in the
outer catch is incorrect (a partially-completed callee may hold live
references, e.g. hooks stored in the export table).
- Changeset: drop implementation details from the changelog entry.
@ndisidore
ndisidore merged commit 2de5871 into mainAug 17, 2026
9 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 17, 2026
ndisidore added a commit that referenced this pull request Aug 20, 2026
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
ndisidore added a commit that referenced this pull request Aug 20, 2026
* feat: construct RpcPromise from a Promise
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
* fix: address review feedback on RpcPromise promise construction
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
* fix: address code-review findings on RpcPromise-from-Promise
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
* docs: remove constructor special-case paragraphs per review
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
* fix: repair dup(), onRpcBroken, and property adoption for promise-wrapped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
* fix: let rejection reject the backing promise instead of adopting ErrorStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
dimitropoulos added a commit that referenced this pull request Aug 21, 2026
0.12.0 added a way to construct an `RpcPromise` from an ordinary `Promise`, so
callers can pipeline against a capability you have not obtained yet. That is
new public API, not a bug fix, and the docs said nothing about it -- main
documented it in the root README, which this branch replaced with a pointer to
here, so the merge would otherwise have dropped it on the floor.
`concepts/promises.md` gets a section, and it leans on the equivalence the API
docs make: wrapping a promise means the same thing as a local-loopback call
returning it. That is worth stating plainly, because it derives all the rules
that would otherwise have to be listed as a second set to memorise -- the
resolution is serialized, targets and functions come back as stubs, ownership
transfers, rejections propagate. The ownership one is the sharp edge and is
called out: disposing the promise disposes the resolution, so resolve with a
`.dup()` if you want to keep a stub.
The cheat sheet gets a one-line constructor note, matching the one `RpcStub`
already has.
Also: a Changelog entry in the sidebar under Reference, pointing at the GitHub
releases page. Off-site on purpose -- release notes are generated from
changesets on every publish, so a page here would be a copy that goes stale the
next time anyone ships. Nimbus recognises the absolute URL and adds
`target="_blank" rel="noopener"` itself.
Nothing else in the merge needed documenting. #241, #243, #251 and #253 are
leak and typing fixes with no API surface to describe; #253's note that
`RpcPromise<RpcStub<T>>` should now be written `RpcPromise<T>` applies to no
annotation anywhere in these pages.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ndisidore@kentonv
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: dispose call args on all failure paths per new StubHook ownership contract by ndisidore · Pull Request #241 · cloudflare/capnweb · GitHub
Skip to content

fix: dispose call args on all failure paths per new StubHook ownership contract - #241

Merged
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal
Aug 17, 2026
Merged

fix: dispose call args on all failure paths per new StubHook ownership contract#241
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

RPC call arguments (and map captures) leaked whenever a call failed before reaching a callee. A rejected pipeline promise, a broken or disposed stub, a failed argument serialization, or a sync throw inside a hook.

Practical example:

using api=newWebSocketRpcSession<Api>("wss://example.com/api");letsession=api.authenticate(token);// NOT awaited — returns a promise stub
using uploader=session.getUploader();// pipelined on the unresolved promiseuploader.write(chunk1,progressCallback);// pipelined further, passes a capabilityuploader.write(chunk2,progressCallback);

lets say token is expired so authenticate() rejects. Every call pipelined behind it had already deep-copied its arguments, including a dup of progressCallback's hook. Before this PR, those copies were simply dropped on the rejection path: progressCallback's disposer never runs, its entry stays pinned.

Rather than patching each site with caller-side try/catch (as an earlier revision of this PR did), the fix defines the rule once on the abstract StubHook: call(), stream(), and map() take ownership of their args/captures even when they throw synchronously, and callers never dispose after invoking.

The implementations that violated this are fixed

  • RpcImportHook.call()/stream() (including mid-serialization failures, where disposal is safe because exported hooks are dups and the Devaluator rolls back its exports)
  • -the default stream() (which leaked the result hook when pull() threw)
  • MapVariableHook
  • the map placeholder

PromiseStubHook now disposes its copied args only when its backing promise rejects i.e. where no callee ever existed to take them.

Also kept from the original PR: PromiseStubHook.dispose() chains on the backing promise so disposal stays ordered behind already-queued calls.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e7bea9

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

This PR includes changesets to release 1 package
NameType
capnwebPatch

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

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@241

commit: 7e7bea9

@ask-bonk

This comment was marked as outdated.

@ndisidore

ndisidore commented Aug 12, 2026

Copy link
Copy Markdown
MemberAuthor

One semantics question I hit while in here: if a call's args contain unresolved promises, deliverCall() waits on them before invoking the target, so a dispose() right after the call can win the race and the method runs on a disposed target.

Pre-existing stuff: this PR just keeps disposal from overtaking forwarding, same as calling the destination hook directly. Is that intended, or should disposal also wait for in-flight deliveries? (That'd be a deliverCall/TargetStubHook lifetime change, so I didn't touch it.)

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 77ff252 to 1cd41dcCompareAugust 12, 2026 20:50
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 20:51
@ask-bonk

This comment was marked as outdated.

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 1cd41dc to ed803feCompareAugust 12, 2026 21:35
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
ndisidoreand others added 4 commits August 13, 2026 20:28
…ind queued calls
PromiseStubHook.call() and .stream() deep-copy their arguments before
chaining on the backing promise, but if that promise rejects, the copies
were never disposed, leaking any stubs they contained.
PromiseStubHook.dispose() also had a fast path that disposed the
resolution synchronously once available. A call chained on the promise
just before disposal could then be delivered after its target was
already disposed, violating ordering. Disposal now always chains on the
promise so it stays behind previously queued calls.
ErrorStubHook.call() ignored the arguments it takes ownership of, and
ErrorStubHook.map() likewise ignored its captures, so anything forwarded
to a broken or disposed hook leaked. ValueStubHook.call() had the same
gap on its error path (its own map() already disposes captures there),
and PromiseStubHook's forwarding continuations did not clean up when the
destination hook threw synchronously.
Co-authored-by: Kenton Varda <kenton@cloudflare.com>
… args
Document on the abstract StubHook that call(), stream(), and map() take
ownership of their args/captures even on synchronous throw, and fix the
implementations that violated it:
- RpcImportHook.call()/stream(): dispose args if getEntry() throws, and in
sendCall()/sendStream() when aborted or when argument serialization fails
(safe: exported hooks are dups and the Devaluator rolls back its exports).
- Default StubHook.stream(): dispose the result hook if pull() throws.
- MapVariableHook and the map-not-loaded placeholder: dispose args/captures
before throwing.
- ValueStubHook.call(): restructure to the inner-catch pattern so delegation
clearly hands ownership to the delegate.
- PromiseStubHook: drop the success-path try/catch around hook.call()/
hook.stream() -- per the contract the resolved callee owns the args; keep
disposal only on rejection, where no callee ever existed.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from b9f79da to c93a265CompareAugust 14, 2026 02:02
- RpcImportHook: collapse the three hand-rolled getEntry() guards in
call()/stream()/map() into a private getEntryTakingOwnership() helper.
- ValueStubHook.map(): flatten the nested try/catch into a single catch that
disposes captures defensively (dispose() is idempotent), matching call()'s
flat shape.
- Tests: SyncThrowingHook extends ErrorStubHook instead of hand-stubbing all
eight abstract StubHook methods.
- Condense the changeset to changelog style.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from c93a265 to a8be070CompareAugust 14, 2026 02:06
@ndisidorendisidore changed the title fix: PromiseStubHook leaks call args on rejection and can dispose out of orderfix: dispose call args on all failure paths per new StubHook ownership contractAug 14, 2026
Comment threadsrc/core.ts Outdated
Comment thread.changeset/promise-stubhook-disposal.md Outdated
Comment threadsrc/core.ts
- Revert the mapImpl placeholder change: the stubs are always replaced at
startup, so handling disposal there is dead code.
- ValueStubHook.map(): restore the narrow inner catch. Once ownership of the
captures transfers to the delegate or applyMap(), disposing them in the
outer catch is incorrect (a partially-completed callee may hold live
references, e.g. hooks stored in the export table).
- Changeset: drop implementation details from the changelog entry.
@ndisidore
ndisidore merged commit 2de5871 into mainAug 17, 2026
9 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 17, 2026
ndisidore added a commit that referenced this pull request Aug 20, 2026
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
ndisidore added a commit that referenced this pull request Aug 20, 2026
* feat: construct RpcPromise from a Promise
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
* fix: address review feedback on RpcPromise promise construction
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
* fix: address code-review findings on RpcPromise-from-Promise
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
* docs: remove constructor special-case paragraphs per review
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
* fix: repair dup(), onRpcBroken, and property adoption for promise-wrapped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
* fix: let rejection reject the backing promise instead of adopting ErrorStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
dimitropoulos added a commit that referenced this pull request Aug 21, 2026
0.12.0 added a way to construct an `RpcPromise` from an ordinary `Promise`, so
callers can pipeline against a capability you have not obtained yet. That is
new public API, not a bug fix, and the docs said nothing about it -- main
documented it in the root README, which this branch replaced with a pointer to
here, so the merge would otherwise have dropped it on the floor.
`concepts/promises.md` gets a section, and it leans on the equivalence the API
docs make: wrapping a promise means the same thing as a local-loopback call
returning it. That is worth stating plainly, because it derives all the rules
that would otherwise have to be listed as a second set to memorise -- the
resolution is serialized, targets and functions come back as stubs, ownership
transfers, rejections propagate. The ownership one is the sharp edge and is
called out: disposing the promise disposes the resolution, so resolve with a
`.dup()` if you want to keep a stub.
The cheat sheet gets a one-line constructor note, matching the one `RpcStub`
already has.
Also: a Changelog entry in the sidebar under Reference, pointing at the GitHub
releases page. Off-site on purpose -- release notes are generated from
changesets on every publish, so a page here would be a copy that goes stale the
next time anyone ships. Nimbus recognises the absolute URL and adds
`target="_blank" rel="noopener"` itself.
Nothing else in the merge needed documenting. #241, #243, #251 and #253 are
leak and typing fixes with no API surface to describe; #253's note that
`RpcPromise<RpcStub<T>>` should now be written `RpcPromise<T>` applies to no
annotation anywhere in these pages.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ndisidore@kentonv
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: dispose call args on all failure paths per new StubHook ownership contract by ndisidore · Pull Request #241 · cloudflare/capnweb · GitHub
Skip to content

fix: dispose call args on all failure paths per new StubHook ownership contract - #241

Merged
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal
Aug 17, 2026
Merged

fix: dispose call args on all failure paths per new StubHook ownership contract#241
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

RPC call arguments (and map captures) leaked whenever a call failed before reaching a callee. A rejected pipeline promise, a broken or disposed stub, a failed argument serialization, or a sync throw inside a hook.

Practical example:

using api=newWebSocketRpcSession<Api>("wss://example.com/api");letsession=api.authenticate(token);// NOT awaited — returns a promise stub
using uploader=session.getUploader();// pipelined on the unresolved promiseuploader.write(chunk1,progressCallback);// pipelined further, passes a capabilityuploader.write(chunk2,progressCallback);

lets say token is expired so authenticate() rejects. Every call pipelined behind it had already deep-copied its arguments, including a dup of progressCallback's hook. Before this PR, those copies were simply dropped on the rejection path: progressCallback's disposer never runs, its entry stays pinned.

Rather than patching each site with caller-side try/catch (as an earlier revision of this PR did), the fix defines the rule once on the abstract StubHook: call(), stream(), and map() take ownership of their args/captures even when they throw synchronously, and callers never dispose after invoking.

The implementations that violated this are fixed

  • RpcImportHook.call()/stream() (including mid-serialization failures, where disposal is safe because exported hooks are dups and the Devaluator rolls back its exports)
  • -the default stream() (which leaked the result hook when pull() threw)
  • MapVariableHook
  • the map placeholder

PromiseStubHook now disposes its copied args only when its backing promise rejects i.e. where no callee ever existed to take them.

Also kept from the original PR: PromiseStubHook.dispose() chains on the backing promise so disposal stays ordered behind already-queued calls.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e7bea9

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

This PR includes changesets to release 1 package
NameType
capnwebPatch

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

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@241

commit: 7e7bea9

@ask-bonk

This comment was marked as outdated.

@ndisidore

ndisidore commented Aug 12, 2026

Copy link
Copy Markdown
MemberAuthor

One semantics question I hit while in here: if a call's args contain unresolved promises, deliverCall() waits on them before invoking the target, so a dispose() right after the call can win the race and the method runs on a disposed target.

Pre-existing stuff: this PR just keeps disposal from overtaking forwarding, same as calling the destination hook directly. Is that intended, or should disposal also wait for in-flight deliveries? (That'd be a deliverCall/TargetStubHook lifetime change, so I didn't touch it.)

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 77ff252 to 1cd41dcCompareAugust 12, 2026 20:50
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 20:51
@ask-bonk

This comment was marked as outdated.

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 1cd41dc to ed803feCompareAugust 12, 2026 21:35
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
ndisidoreand others added 4 commits August 13, 2026 20:28
…ind queued calls
PromiseStubHook.call() and .stream() deep-copy their arguments before
chaining on the backing promise, but if that promise rejects, the copies
were never disposed, leaking any stubs they contained.
PromiseStubHook.dispose() also had a fast path that disposed the
resolution synchronously once available. A call chained on the promise
just before disposal could then be delivered after its target was
already disposed, violating ordering. Disposal now always chains on the
promise so it stays behind previously queued calls.
ErrorStubHook.call() ignored the arguments it takes ownership of, and
ErrorStubHook.map() likewise ignored its captures, so anything forwarded
to a broken or disposed hook leaked. ValueStubHook.call() had the same
gap on its error path (its own map() already disposes captures there),
and PromiseStubHook's forwarding continuations did not clean up when the
destination hook threw synchronously.
Co-authored-by: Kenton Varda <kenton@cloudflare.com>
… args
Document on the abstract StubHook that call(), stream(), and map() take
ownership of their args/captures even on synchronous throw, and fix the
implementations that violated it:
- RpcImportHook.call()/stream(): dispose args if getEntry() throws, and in
sendCall()/sendStream() when aborted or when argument serialization fails
(safe: exported hooks are dups and the Devaluator rolls back its exports).
- Default StubHook.stream(): dispose the result hook if pull() throws.
- MapVariableHook and the map-not-loaded placeholder: dispose args/captures
before throwing.
- ValueStubHook.call(): restructure to the inner-catch pattern so delegation
clearly hands ownership to the delegate.
- PromiseStubHook: drop the success-path try/catch around hook.call()/
hook.stream() -- per the contract the resolved callee owns the args; keep
disposal only on rejection, where no callee ever existed.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from b9f79da to c93a265CompareAugust 14, 2026 02:02
- RpcImportHook: collapse the three hand-rolled getEntry() guards in
call()/stream()/map() into a private getEntryTakingOwnership() helper.
- ValueStubHook.map(): flatten the nested try/catch into a single catch that
disposes captures defensively (dispose() is idempotent), matching call()'s
flat shape.
- Tests: SyncThrowingHook extends ErrorStubHook instead of hand-stubbing all
eight abstract StubHook methods.
- Condense the changeset to changelog style.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from c93a265 to a8be070CompareAugust 14, 2026 02:06
@ndisidorendisidore changed the title fix: PromiseStubHook leaks call args on rejection and can dispose out of orderfix: dispose call args on all failure paths per new StubHook ownership contractAug 14, 2026
Comment threadsrc/core.ts Outdated
Comment thread.changeset/promise-stubhook-disposal.md Outdated
Comment threadsrc/core.ts
- Revert the mapImpl placeholder change: the stubs are always replaced at
startup, so handling disposal there is dead code.
- ValueStubHook.map(): restore the narrow inner catch. Once ownership of the
captures transfers to the delegate or applyMap(), disposing them in the
outer catch is incorrect (a partially-completed callee may hold live
references, e.g. hooks stored in the export table).
- Changeset: drop implementation details from the changelog entry.
@ndisidore
ndisidore merged commit 2de5871 into mainAug 17, 2026
9 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 17, 2026
ndisidore added a commit that referenced this pull request Aug 20, 2026
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
ndisidore added a commit that referenced this pull request Aug 20, 2026
* feat: construct RpcPromise from a Promise
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
* fix: address review feedback on RpcPromise promise construction
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
* fix: address code-review findings on RpcPromise-from-Promise
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
* docs: remove constructor special-case paragraphs per review
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
* fix: repair dup(), onRpcBroken, and property adoption for promise-wrapped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
* fix: let rejection reject the backing promise instead of adopting ErrorStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
dimitropoulos added a commit that referenced this pull request Aug 21, 2026
0.12.0 added a way to construct an `RpcPromise` from an ordinary `Promise`, so
callers can pipeline against a capability you have not obtained yet. That is
new public API, not a bug fix, and the docs said nothing about it -- main
documented it in the root README, which this branch replaced with a pointer to
here, so the merge would otherwise have dropped it on the floor.
`concepts/promises.md` gets a section, and it leans on the equivalence the API
docs make: wrapping a promise means the same thing as a local-loopback call
returning it. That is worth stating plainly, because it derives all the rules
that would otherwise have to be listed as a second set to memorise -- the
resolution is serialized, targets and functions come back as stubs, ownership
transfers, rejections propagate. The ownership one is the sharp edge and is
called out: disposing the promise disposes the resolution, so resolve with a
`.dup()` if you want to keep a stub.
The cheat sheet gets a one-line constructor note, matching the one `RpcStub`
already has.
Also: a Changelog entry in the sidebar under Reference, pointing at the GitHub
releases page. Off-site on purpose -- release notes are generated from
changesets on every publish, so a page here would be a copy that goes stale the
next time anyone ships. Nimbus recognises the absolute URL and adds
`target="_blank" rel="noopener"` itself.
Nothing else in the merge needed documenting. #241, #243, #251 and #253 are
leak and typing fixes with no API surface to describe; #253's note that
`RpcPromise<RpcStub<T>>` should now be written `RpcPromise<T>` applies to no
annotation anywhere in these pages.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ndisidore@kentonv
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: dispose call args on all failure paths per new StubHook ownership contract by ndisidore · Pull Request #241 · cloudflare/capnweb · GitHub
Skip to content

fix: dispose call args on all failure paths per new StubHook ownership contract - #241

Merged
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal
Aug 17, 2026
Merged

fix: dispose call args on all failure paths per new StubHook ownership contract#241
ndisidore merged 6 commits into
mainfrom
fix/promise-stubhook-disposal

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

RPC call arguments (and map captures) leaked whenever a call failed before reaching a callee. A rejected pipeline promise, a broken or disposed stub, a failed argument serialization, or a sync throw inside a hook.

Practical example:

using api=newWebSocketRpcSession<Api>("wss://example.com/api");letsession=api.authenticate(token);// NOT awaited — returns a promise stub
using uploader=session.getUploader();// pipelined on the unresolved promiseuploader.write(chunk1,progressCallback);// pipelined further, passes a capabilityuploader.write(chunk2,progressCallback);

lets say token is expired so authenticate() rejects. Every call pipelined behind it had already deep-copied its arguments, including a dup of progressCallback's hook. Before this PR, those copies were simply dropped on the rejection path: progressCallback's disposer never runs, its entry stays pinned.

Rather than patching each site with caller-side try/catch (as an earlier revision of this PR did), the fix defines the rule once on the abstract StubHook: call(), stream(), and map() take ownership of their args/captures even when they throw synchronously, and callers never dispose after invoking.

The implementations that violated this are fixed

  • RpcImportHook.call()/stream() (including mid-serialization failures, where disposal is safe because exported hooks are dups and the Devaluator rolls back its exports)
  • -the default stream() (which leaked the result hook when pull() threw)
  • MapVariableHook
  • the map placeholder

PromiseStubHook now disposes its copied args only when its backing promise rejects i.e. where no callee ever existed to take them.

Also kept from the original PR: PromiseStubHook.dispose() chains on the backing promise so disposal stays ordered behind already-queued calls.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e7bea9

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

This PR includes changesets to release 1 package
NameType
capnwebPatch

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

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@241

commit: 7e7bea9

@ask-bonk

This comment was marked as outdated.

@ndisidore

ndisidore commented Aug 12, 2026

Copy link
Copy Markdown
MemberAuthor

One semantics question I hit while in here: if a call's args contain unresolved promises, deliverCall() waits on them before invoking the target, so a dispose() right after the call can win the race and the method runs on a disposed target.

Pre-existing stuff: this PR just keeps disposal from overtaking forwarding, same as calling the destination hook directly. Is that intended, or should disposal also wait for in-flight deliveries? (That'd be a deliverCall/TargetStubHook lifetime change, so I didn't touch it.)

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 77ff252 to 1cd41dcCompareAugust 12, 2026 20:50
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 20:51
@ask-bonk

This comment was marked as outdated.

@ndisidore

Copy link
Copy Markdown
MemberAuthor

/bonk review this

@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from 1cd41dc to ed803feCompareAugust 12, 2026 21:35
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
ndisidoreand others added 4 commits August 13, 2026 20:28
…ind queued calls
PromiseStubHook.call() and .stream() deep-copy their arguments before
chaining on the backing promise, but if that promise rejects, the copies
were never disposed, leaking any stubs they contained.
PromiseStubHook.dispose() also had a fast path that disposed the
resolution synchronously once available. A call chained on the promise
just before disposal could then be delivered after its target was
already disposed, violating ordering. Disposal now always chains on the
promise so it stays behind previously queued calls.
ErrorStubHook.call() ignored the arguments it takes ownership of, and
ErrorStubHook.map() likewise ignored its captures, so anything forwarded
to a broken or disposed hook leaked. ValueStubHook.call() had the same
gap on its error path (its own map() already disposes captures there),
and PromiseStubHook's forwarding continuations did not clean up when the
destination hook threw synchronously.
Co-authored-by: Kenton Varda <kenton@cloudflare.com>
… args
Document on the abstract StubHook that call(), stream(), and map() take
ownership of their args/captures even on synchronous throw, and fix the
implementations that violated it:
- RpcImportHook.call()/stream(): dispose args if getEntry() throws, and in
sendCall()/sendStream() when aborted or when argument serialization fails
(safe: exported hooks are dups and the Devaluator rolls back its exports).
- Default StubHook.stream(): dispose the result hook if pull() throws.
- MapVariableHook and the map-not-loaded placeholder: dispose args/captures
before throwing.
- ValueStubHook.call(): restructure to the inner-catch pattern so delegation
clearly hands ownership to the delegate.
- PromiseStubHook: drop the success-path try/catch around hook.call()/
hook.stream() -- per the contract the resolved callee owns the args; keep
disposal only on rejection, where no callee ever existed.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from b9f79da to c93a265CompareAugust 14, 2026 02:02
- RpcImportHook: collapse the three hand-rolled getEntry() guards in
call()/stream()/map() into a private getEntryTakingOwnership() helper.
- ValueStubHook.map(): flatten the nested try/catch into a single catch that
disposes captures defensively (dispose() is idempotent), matching call()'s
flat shape.
- Tests: SyncThrowingHook extends ErrorStubHook instead of hand-stubbing all
eight abstract StubHook methods.
- Condense the changeset to changelog style.
@ndisidore
ndisidoreforce-pushed the fix/promise-stubhook-disposal branch from c93a265 to a8be070CompareAugust 14, 2026 02:06
@ndisidorendisidore changed the title fix: PromiseStubHook leaks call args on rejection and can dispose out of orderfix: dispose call args on all failure paths per new StubHook ownership contractAug 14, 2026
Comment threadsrc/core.ts Outdated
Comment thread.changeset/promise-stubhook-disposal.md Outdated
Comment threadsrc/core.ts
- Revert the mapImpl placeholder change: the stubs are always replaced at
startup, so handling disposal there is dead code.
- ValueStubHook.map(): restore the narrow inner catch. Once ownership of the
captures transfers to the delegate or applyMap(), disposing them in the
outer catch is incorrect (a partially-completed callee may hold live
references, e.g. hooks stored in the export table).
- Changeset: drop implementation details from the changelog entry.
@ndisidore
ndisidore merged commit 2de5871 into mainAug 17, 2026
9 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 17, 2026
ndisidore added a commit that referenced this pull request Aug 20, 2026
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
ndisidore added a commit that referenced this pull request Aug 20, 2026
* feat: construct RpcPromise from a Promise
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
* fix: address review feedback on RpcPromise promise construction
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
* fix: address code-review findings on RpcPromise-from-Promise
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
* docs: remove constructor special-case paragraphs per review
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
* fix: repair dup(), onRpcBroken, and property adoption for promise-wrapped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
* fix: let rejection reject the backing promise instead of adopting ErrorStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
dimitropoulos added a commit that referenced this pull request Aug 21, 2026
0.12.0 added a way to construct an `RpcPromise` from an ordinary `Promise`, so
callers can pipeline against a capability you have not obtained yet. That is
new public API, not a bug fix, and the docs said nothing about it -- main
documented it in the root README, which this branch replaced with a pointer to
here, so the merge would otherwise have dropped it on the floor.
`concepts/promises.md` gets a section, and it leans on the equivalence the API
docs make: wrapping a promise means the same thing as a local-loopback call
returning it. That is worth stating plainly, because it derives all the rules
that would otherwise have to be listed as a second set to memorise -- the
resolution is serialized, targets and functions come back as stubs, ownership
transfers, rejections propagate. The ownership one is the sharp edge and is
called out: disposing the promise disposes the resolution, so resolve with a
`.dup()` if you want to keep a stub.
The cheat sheet gets a one-line constructor note, matching the one `RpcStub`
already has.
Also: a Changelog entry in the sidebar under Reference, pointing at the GitHub
releases page. Off-site on purpose -- release notes are generated from
changesets on every publish, so a page here would be a copy that goes stale the
next time anyone ships. Nimbus recognises the absolute URL and adds
`target="_blank" rel="noopener"` itself.
Nothing else in the merge needed documenting. #241, #243, #251 and #253 are
leak and typing fixes with no API surface to describe; #253's note that
`RpcPromise<RpcStub<T>>` should now be written `RpcPromise<T>` applies to no
annotation anywhere in these pages.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ndisidore@kentonv