Skip to content
File

Blob: src/workerd/api/tests/js-rpc-params-ownership-test.js

javascript193 lines
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
4import assert from 'node:assert';
5import { WorkerEntrypoint, RpcTarget, RpcStub } from 'cloudflare:workers';
6 
7class 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 
22class 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 
43export 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.
55export 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.
85export 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.
102export 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.)
123export 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.
143export 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.
177export 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};