From 42ade272a9bdf82d1385ac05396816cce7f0bf55 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Thu, 21 May 2026 02:36:19 -0700 Subject: [PATCH 1/3] docs: add callback retention example to Duplicating stubs --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 02434c1..2e4382e 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,27 @@ Sometimes you need to pass a stub somewhere where it will be disposed, but also Hint: You can call `.dup()` on a property of a stub or promise, in order to create a stub backed by that property. This is particularly useful when you know in advance that the property is going to resolve to a stub: calling `.dup()` on it gives you a stub you can start using immediately, that otherwise behaves exactly the same as the eventual stub would if you awaited it. +#### Holding on to a callback past the call that delivered it + +A common bidirectional-calling pattern is for the client to pass a callback to the server, which the server then invokes later (for example from a timer, an event handler, or a subsequent RPC). Because the callback parameter is a stub, and stubs in params are implicitly disposed when the call returns, the server must duplicate the stub with `.dup()` if it wants to invoke the callback after the call completes: + +```ts +class Api extends RpcTarget { + async setCallback(cbfunc) { + // Without .dup(), `cbfunc` is disposed when setCallback() returns. + this.cbfunc = cbfunc.dup(); + this.timer = setInterval(() => this.cbfunc("tick"), 1000); + } + + [Symbol.dispose]() { + clearInterval(this.timer); + this.cbfunc[Symbol.dispose](); + } +} +``` + +The same rule applies in the other direction: if the server returns a stub to the client and the client wants to keep using it after disposing the result, the client should `.dup()` the stub before the result is disposed. + ### Listening for disposal An `RpcTarget` may declare a `Symbol.dispose` method. If it does, the RPC system will automatically invoke it when a stub pointing at it (and all its duplicates) has been disposed. From ee055ded46c977ed35b92e48f8c7373a5bdaec08 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Sun, 26 Jul 2026 16:29:18 -0400 Subject: [PATCH 2/3] update example --- README.md | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2e4382e..b375b7b 100644 --- a/README.md +++ b/README.md @@ -383,16 +383,37 @@ Hint: You can call `.dup()` on a property of a stub or promise, in order to crea A common bidirectional-calling pattern is for the client to pass a callback to the server, which the server then invokes later (for example from a timer, an event handler, or a subsequent RPC). Because the callback parameter is a stub, and stubs in params are implicitly disposed when the call returns, the server must duplicate the stub with `.dup()` if it wants to invoke the callback after the call completes: ```ts +import { type RpcStub, RpcTarget } from 'capnweb'; + +// The callback the client passes in: a stub wrapping a function. +type CallbackFunc = RpcStub<(msg: string) => void>; + class Api extends RpcTarget { - async setCallback(cbfunc) { - // Without .dup(), `cbfunc` is disposed when setCallback() returns. - this.cbfunc = cbfunc.dup(); - this.timer = setInterval(() => this.cbfunc("tick"), 1000); + #callbackFunc?: CallbackFunc; + #timer?: NodeJS.Timeout; + + async setCallback(callbackFunc: CallbackFunc) { + // release any previously-registered callback so repeated calls don't leak. + this.#stop(); + + // stubs passed as params are implicitly disposed when the call returns. + // but `.dup()` gives us our own reference that outlives `setCallback()`. + this.#callbackFunc = callbackFunc.dup(); + this.#timer = setInterval(() => this.#callbackFunc?.("tick"), 1000); + } + + #stop() { + if (this.#timer !== undefined) { + clearInterval(this.#timer); + this.#timer = undefined; + } + // Dispose our duplicate so the client-side stub can be freed. + this.#callbackFunc?.[Symbol.dispose](); + this.#callbackFunc = undefined; // maintain idempotency } [Symbol.dispose]() { - clearInterval(this.timer); - this.cbfunc[Symbol.dispose](); + this.#stop(); } } ``` From 872efaad89fb82e1ddd2070f84e867def7c94278 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Mon, 27 Jul 2026 12:29:15 +0000 Subject: [PATCH 3/3] docs: invoke retained callback from a later RPC in dup() example Invoke the retained callback from a separate RPC method so the example stays within the RPC domain and centers on the dup()/dispose lifecycle. Uses no runtime-specific timer types, so it type-checks on any runtime. --- README.md | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b375b7b..10aa4f4 100644 --- a/README.md +++ b/README.md @@ -385,35 +385,27 @@ A common bidirectional-calling pattern is for the client to pass a callback to t ```ts import { type RpcStub, RpcTarget } from 'capnweb'; -// The callback the client passes in: a stub wrapping a function. -type CallbackFunc = RpcStub<(msg: string) => void>; +// A callback the client passes in: a stub wrapping a function. +type Listener = RpcStub<(msg: string) => void>; class Api extends RpcTarget { - #callbackFunc?: CallbackFunc; - #timer?: NodeJS.Timeout; + #listener?: Listener; - async setCallback(callbackFunc: CallbackFunc) { - // release any previously-registered callback so repeated calls don't leak. - this.#stop(); - - // stubs passed as params are implicitly disposed when the call returns. - // but `.dup()` gives us our own reference that outlives `setCallback()`. - this.#callbackFunc = callbackFunc.dup(); - this.#timer = setInterval(() => this.#callbackFunc?.("tick"), 1000); + // Stubs passed as params are disposed when the call returns, so `.dup()` + // to keep a reference that outlives registerListener(). + registerListener(listener: Listener) { + this.#listener?.[Symbol.dispose](); // release any previous listener + this.#listener = listener.dup(); } - #stop() { - if (this.#timer !== undefined) { - clearInterval(this.#timer); - this.#timer = undefined; - } - // Dispose our duplicate so the client-side stub can be freed. - this.#callbackFunc?.[Symbol.dispose](); - this.#callbackFunc = undefined; // maintain idempotency + // A *later* call can invoke the retained callback -- still valid thanks to .dup(). + notify(msg: string) { + this.#listener?.(msg); } + // Dispose our duplicate when done so the client-side stub can be freed. [Symbol.dispose]() { - this.#stop(); + this.#listener?.[Symbol.dispose](); } } ```