File
Blob: src/workerd/api/tests/js-rpc-params-ownership-test.js
| 1 | // Copyright (c) 2025 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | import assert from 'node:assert'; |
| 5 | import { WorkerEntrypoint, RpcTarget, RpcStub } from 'cloudflare:workers'; |
| 6 | |
| 7 | class Counter extends RpcTarget { |
| 8 | count = { value: 0 }; |
| 9 | dupCounts = { created: 0, disposed: 0 }; |
| 10 | disposeCount = 0; |
| 11 | |
| 12 | increment(amount = 1) { |
| 13 | this.count.value += amount; |
| 14 | return this.count.value; |
| 15 | } |
| 16 | |
| 17 | [Symbol.dispose]() { |
| 18 | ++this.disposeCount; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | class DupableCounter extends Counter { |
| 23 | disposed = false; |
| 24 | |
| 25 | dup() { |
| 26 | let result = new DupableCounter(); |
| 27 | result.count = this.count; |
| 28 | result.dupCounts = this.dupCounts; |
| 29 | ++this.dupCounts.created; |
| 30 | return result; |
| 31 | } |
| 32 | |
| 33 | [Symbol.dispose]() { |
| 34 | if (this.disposed) { |
| 35 | throw new Error('duplicate disposal'); |
| 36 | } |
| 37 | this.disposed = true; |
| 38 | ++this.dupCounts.disposed; |
| 39 | ++this.disposeCount; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | export class TestService extends WorkerEntrypoint { |
| 44 | async increment(stub, i) { |
| 45 | await stub.increment(i); |
| 46 | } |
| 47 | |
| 48 | async roundTrip(stub) { |
| 49 | return { stub: stub.dup() }; |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // Test that (with the rpc_params_dup_stubs compat flag) passing a stub in RPC params doesn't |
| 54 | // transfer ownership of the stub. |
| 55 | export let rpcParamsDontTransferOwnership = { |
| 56 | async test(controller, env, ctx) { |
| 57 | let counter = new Counter(); |
| 58 | |
| 59 | { |
| 60 | using stub = new RpcStub(counter); |
| 61 | |
| 62 | // Use the stub in params twice to prove that ownership isn't transferred away. |
| 63 | await ctx.exports.TestService.increment(stub, 2); |
| 64 | await ctx.exports.TestService.increment(stub, 3); |
| 65 | |
| 66 | // Make extra-sure we can still call the stub. |
| 67 | await stub.increment(); |
| 68 | |
| 69 | assert.strictEqual(counter.count.value, 6); |
| 70 | |
| 71 | // RpcTarget disposer should not have been called at all. |
| 72 | await scheduler.wait(0); |
| 73 | assert.strictEqual(counter.disposeCount, 0); |
| 74 | } |
| 75 | |
| 76 | // Disposing a stub *asynchrconously* disposes the RpcTarget, so we have to spin the event |
| 77 | // loop to observe the disposal. |
| 78 | await scheduler.wait(0); |
| 79 | assert.strictEqual(counter.disposeCount, 1); |
| 80 | }, |
| 81 | }; |
| 82 | |
| 83 | // Test that placing a plain RpcTarget in RPC params DOES "take ownership", that is, the disposer |
| 84 | // will be called. |
| 85 | export let rpcParamsPlainTarget = { |
| 86 | async test(controller, env, ctx) { |
| 87 | let counter = new Counter(); |
| 88 | |
| 89 | await ctx.exports.TestService.increment(counter, 2); |
| 90 | await ctx.exports.TestService.increment(counter, 3); |
| 91 | |
| 92 | assert.strictEqual(counter.count.value, 5); |
| 93 | |
| 94 | // Each RPC invocation will have called the disposer. |
| 95 | await scheduler.wait(0); |
| 96 | assert.strictEqual(counter.disposeCount, 2); |
| 97 | }, |
| 98 | }; |
| 99 | |
| 100 | // Test that placing an RpcTarget with a dup() method in RPC params causes the dup() method to be |
| 101 | // called, and then the duplicate is later disposed. |
| 102 | export let rpcParamsDupTarget = { |
| 103 | async test(controller, env, ctx) { |
| 104 | let counter = new DupableCounter(); |
| 105 | |
| 106 | // If we directly pass `counter` to RPC params, it'll be dup()ed. |
| 107 | await ctx.exports.TestService.increment(counter, 2); |
| 108 | assert.strictEqual(counter.dupCounts.created, 1); |
| 109 | await ctx.exports.TestService.increment(counter, 3); |
| 110 | assert.strictEqual(counter.dupCounts.created, 2); |
| 111 | |
| 112 | assert.strictEqual(counter.count.value, 5); |
| 113 | |
| 114 | // Dups should have been disposed, but not original. |
| 115 | await scheduler.wait(0); |
| 116 | assert.strictEqual(counter.dupCounts.disposed, 2); |
| 117 | assert.strictEqual(counter.disposed, false); |
| 118 | }, |
| 119 | }; |
| 120 | |
| 121 | // Like rpcParamsDupTarget but the target is wrapped in a Proxy. (This takes a different code |
| 122 | // path.) |
| 123 | export let rpcParamsDupProxyTarget = { |
| 124 | async test(controller, env, ctx) { |
| 125 | let counter = new Proxy(new DupableCounter(), {}); |
| 126 | |
| 127 | // If we directly pass `counter` to RPC params, it'll be dup()ed. |
| 128 | await ctx.exports.TestService.increment(counter, 2); |
| 129 | assert.strictEqual(counter.dupCounts.created, 1); |
| 130 | await ctx.exports.TestService.increment(counter, 3); |
| 131 | assert.strictEqual(counter.dupCounts.created, 2); |
| 132 | |
| 133 | assert.strictEqual(counter.count.value, 5); |
| 134 | |
| 135 | // Dups should have been disposed, but not original. |
| 136 | await scheduler.wait(0); |
| 137 | assert.strictEqual(counter.dupCounts.disposed, 2); |
| 138 | assert.strictEqual(counter.disposed, false); |
| 139 | }, |
| 140 | }; |
| 141 | |
| 142 | // Like rpcParamsDupTarget but the target is a function. |
| 143 | export let rpcParamsDupFunction = { |
| 144 | async test(controller, env, ctx) { |
| 145 | let count = 0; |
| 146 | let dupCount = 0; |
| 147 | let disposeCount = 0; |
| 148 | |
| 149 | let increment = (i) => { |
| 150 | return (count += i); |
| 151 | }; |
| 152 | increment.dup = () => { |
| 153 | ++dupCount; |
| 154 | return increment; |
| 155 | }; |
| 156 | increment[Symbol.dispose] = function () { |
| 157 | ++disposeCount; |
| 158 | }; |
| 159 | |
| 160 | let counter = { increment }; |
| 161 | |
| 162 | // If we directly pass `counter` to RPC params, it'll be dup()ed. |
| 163 | await ctx.exports.TestService.increment(counter, 2); |
| 164 | assert.strictEqual(dupCount, 1); |
| 165 | await ctx.exports.TestService.increment(counter, 3); |
| 166 | assert.strictEqual(dupCount, 2); |
| 167 | |
| 168 | assert.strictEqual(count, 5); |
| 169 | |
| 170 | await scheduler.wait(0); |
| 171 | assert.strictEqual(disposeCount, 2); |
| 172 | }, |
| 173 | }; |
| 174 | |
| 175 | // Test that returning a stub tansfers ownership of the stub, that is, the system later disposes |
| 176 | // it. |
| 177 | export let rpcReturnsTransferOwnership = { |
| 178 | async test(controller, env, ctx) { |
| 179 | let counter = new Counter(); |
| 180 | |
| 181 | { |
| 182 | using stub = new RpcStub(counter); |
| 183 | using _stub2 = (await ctx.exports.TestService.roundTrip(stub)).stub; |
| 184 | |
| 185 | await scheduler.wait(0); |
| 186 | assert.strictEqual(counter.disposeCount, 0); |
| 187 | } |
| 188 | |
| 189 | await scheduler.wait(0); |
| 190 | assert.strictEqual(counter.disposeCount, 1); |
| 191 | }, |
| 192 | }; |