@effectionx/worker 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/channel.test.ts +902 -0
- package/channel.ts +380 -0
- package/dist/channel.d.ts +167 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +236 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +137 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +27 -1
- package/dist/worker-main.d.ts +16 -2
- package/dist/worker-main.d.ts.map +1 -1
- package/dist/worker-main.js +78 -6
- package/dist/worker.d.ts +34 -2
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +138 -22
- package/package.json +8 -4
- package/test-assets/bidirectional-worker.ts +19 -0
- package/test-assets/concurrent-requests-worker.ts +9 -0
- package/test-assets/error-cause-worker.ts +19 -0
- package/test-assets/error-handling-worker.ts +12 -0
- package/test-assets/error-throw-worker.ts +9 -0
- package/test-assets/no-requests-worker.ts +5 -0
- package/test-assets/send-inside-foreach-worker.ts +18 -0
- package/test-assets/sequential-requests-worker.ts +10 -0
- package/test-assets/single-request-worker.ts +8 -0
- package/test-assets/slow-request-worker.ts +8 -0
- package/tsconfig.json +3 -0
- package/types.ts +157 -3
- package/worker-main.ts +119 -8
- package/worker.test.ts +302 -4
- package/worker.ts +213 -29
- package/dist/message-channel.d.ts +0 -3
- package/dist/message-channel.d.ts.map +0 -1
- package/dist/message-channel.js +0 -13
- package/message-channel.ts +0 -13
package/channel.test.ts
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import { describe, it } from "@effectionx/bdd";
|
|
2
|
+
import { timebox } from "@effectionx/timebox";
|
|
3
|
+
import { once, race, sleep, spawn, suspend, withResolvers } from "effection";
|
|
4
|
+
import { expect } from "expect";
|
|
5
|
+
|
|
6
|
+
import { useChannelRequest, useChannelResponse } from "./channel.ts";
|
|
7
|
+
import type { SerializedResult } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
describe("channel", () => {
|
|
10
|
+
describe("useChannelResponse", () => {
|
|
11
|
+
it("creates a channel with a transferable port", function* () {
|
|
12
|
+
const response = yield* useChannelResponse<string>();
|
|
13
|
+
|
|
14
|
+
expect(response.port).toBeInstanceOf(MessagePort);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("receives response data via operation", function* () {
|
|
18
|
+
const response = yield* useChannelResponse<string>();
|
|
19
|
+
|
|
20
|
+
// Simulate responder sending a ChannelMessage response
|
|
21
|
+
yield* spawn(function* () {
|
|
22
|
+
response.port.start();
|
|
23
|
+
// New message format: { type: "response", result: SerializedResult }
|
|
24
|
+
response.port.postMessage({
|
|
25
|
+
type: "response",
|
|
26
|
+
result: { ok: true, value: "hello from responder" },
|
|
27
|
+
});
|
|
28
|
+
// Responder would wait for ACK here in real usage
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const result = yield* response;
|
|
32
|
+
expect(result).toEqual({ ok: true, value: "hello from responder" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("sends ACK after receiving response", function* () {
|
|
36
|
+
// Use full round-trip to verify ACK is received
|
|
37
|
+
const response = yield* useChannelResponse<string>();
|
|
38
|
+
|
|
39
|
+
// Spawn responder - it uses useChannelRequest which waits for ACK
|
|
40
|
+
yield* spawn(function* () {
|
|
41
|
+
const { resolve } = yield* useChannelRequest<string>(response.port);
|
|
42
|
+
// This will block until ACK is received
|
|
43
|
+
yield* resolve("response data");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const result = yield* response;
|
|
47
|
+
// If we got here, the ACK was sent and received
|
|
48
|
+
expect(result).toEqual({ ok: true, value: "response data" });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("useChannelRequest", () => {
|
|
53
|
+
// These tests use raw MessageChannel to isolate useChannelRequest behavior.
|
|
54
|
+
// This provides unit test coverage independent of useChannelResponse.
|
|
55
|
+
|
|
56
|
+
it("resolve sends value and waits for ACK", function* () {
|
|
57
|
+
const channel = new MessageChannel();
|
|
58
|
+
channel.port1.start();
|
|
59
|
+
|
|
60
|
+
let messageReceived: unknown = null;
|
|
61
|
+
|
|
62
|
+
// Simulate requester on port1 using effection
|
|
63
|
+
yield* spawn(function* () {
|
|
64
|
+
const event = yield* once(channel.port1, "message");
|
|
65
|
+
messageReceived = (event as MessageEvent).data;
|
|
66
|
+
// Send ACK
|
|
67
|
+
channel.port1.postMessage({ type: "ack" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Responder on port2
|
|
71
|
+
const { resolve } = yield* useChannelRequest<string>(channel.port2);
|
|
72
|
+
yield* resolve("success value");
|
|
73
|
+
|
|
74
|
+
// Value is wrapped in ChannelMessage with SerializedResult
|
|
75
|
+
expect(messageReceived).toEqual({
|
|
76
|
+
type: "response",
|
|
77
|
+
result: { ok: true, value: "success value" },
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reject sends error and waits for ACK", function* () {
|
|
82
|
+
const channel = new MessageChannel();
|
|
83
|
+
channel.port1.start();
|
|
84
|
+
|
|
85
|
+
let messageReceived: unknown = null;
|
|
86
|
+
|
|
87
|
+
// Simulate requester on port1 using effection
|
|
88
|
+
yield* spawn(function* () {
|
|
89
|
+
const event = yield* once(channel.port1, "message");
|
|
90
|
+
messageReceived = (event as MessageEvent).data;
|
|
91
|
+
// Send ACK
|
|
92
|
+
channel.port1.postMessage({ type: "ack" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Responder on port2
|
|
96
|
+
const { reject } = yield* useChannelRequest<string>(channel.port2);
|
|
97
|
+
const error = new Error("test error");
|
|
98
|
+
yield* reject(error);
|
|
99
|
+
|
|
100
|
+
// Error is serialized and wrapped in ChannelMessage
|
|
101
|
+
const msg = messageReceived as {
|
|
102
|
+
type: string;
|
|
103
|
+
result: SerializedResult<string>;
|
|
104
|
+
};
|
|
105
|
+
expect(msg.type).toBe("response");
|
|
106
|
+
expect(msg.result.ok).toBe(false);
|
|
107
|
+
if (!msg.result.ok) {
|
|
108
|
+
expect(msg.result.error.name).toBe("Error");
|
|
109
|
+
expect(msg.result.error.message).toBe("test error");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("throws on invalid ACK message", function* () {
|
|
114
|
+
const channel = new MessageChannel();
|
|
115
|
+
channel.port1.start();
|
|
116
|
+
|
|
117
|
+
// Simulate requester sending wrong ACK using effection
|
|
118
|
+
yield* spawn(function* () {
|
|
119
|
+
yield* once(channel.port1, "message");
|
|
120
|
+
// Send wrong message instead of ACK
|
|
121
|
+
channel.port1.postMessage({ type: "wrong" });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const { resolve } = yield* useChannelRequest<string>(channel.port2);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
yield* resolve("value");
|
|
128
|
+
throw new Error("should have thrown");
|
|
129
|
+
} catch (e) {
|
|
130
|
+
expect(e).toBeInstanceOf(Error);
|
|
131
|
+
expect((e as Error).message).toContain("Expected ack");
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("full round-trip", () => {
|
|
137
|
+
it("requester sends, responder resolves, requester receives", function* () {
|
|
138
|
+
const response = yield* useChannelResponse<string>();
|
|
139
|
+
|
|
140
|
+
// Spawn responder
|
|
141
|
+
yield* spawn(function* () {
|
|
142
|
+
const { resolve } = yield* useChannelRequest<string>(response.port);
|
|
143
|
+
yield* resolve("response from responder");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = yield* response;
|
|
147
|
+
expect(result).toEqual({ ok: true, value: "response from responder" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("requester sends, responder rejects, requester receives error", function* () {
|
|
151
|
+
const response = yield* useChannelResponse<string>();
|
|
152
|
+
|
|
153
|
+
const testError = new Error("responder error");
|
|
154
|
+
|
|
155
|
+
// Spawn responder
|
|
156
|
+
yield* spawn(function* () {
|
|
157
|
+
const { reject } = yield* useChannelRequest<string>(response.port);
|
|
158
|
+
yield* reject(testError);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = yield* response;
|
|
162
|
+
// Error is serialized and wrapped in SerializedResult
|
|
163
|
+
expect(result.ok).toBe(false);
|
|
164
|
+
if (!result.ok) {
|
|
165
|
+
expect(result.error.name).toBe("Error");
|
|
166
|
+
expect(result.error.message).toBe("responder error");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("handles complex data types", function* () {
|
|
171
|
+
interface ComplexData {
|
|
172
|
+
name: string;
|
|
173
|
+
count: number;
|
|
174
|
+
nested: { items: string[] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const response = yield* useChannelResponse<ComplexData>();
|
|
178
|
+
|
|
179
|
+
const testData: ComplexData = {
|
|
180
|
+
name: "test",
|
|
181
|
+
count: 42,
|
|
182
|
+
nested: { items: ["a", "b", "c"] },
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Spawn responder
|
|
186
|
+
yield* spawn(function* () {
|
|
187
|
+
const { resolve } = yield* useChannelRequest<ComplexData>(
|
|
188
|
+
response.port,
|
|
189
|
+
);
|
|
190
|
+
yield* resolve(testData);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const result = yield* response;
|
|
194
|
+
expect(result).toEqual({ ok: true, value: testData });
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("close detection (useChannelResponse)", () => {
|
|
199
|
+
it("errors if responder closes port without responding", function* () {
|
|
200
|
+
const response = yield* useChannelResponse<string>();
|
|
201
|
+
|
|
202
|
+
// Spawn responder that closes without responding
|
|
203
|
+
yield* spawn(function* () {
|
|
204
|
+
response.port.start();
|
|
205
|
+
response.port.close(); // Close without calling resolve/reject
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Requester should get an error
|
|
209
|
+
let error: Error | undefined;
|
|
210
|
+
try {
|
|
211
|
+
yield* response;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
error = e as Error;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
expect(error).toBeDefined();
|
|
217
|
+
expect(error?.message).toContain("closed");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("errors if responder scope exits without responding", function* () {
|
|
221
|
+
const response = yield* useChannelResponse<string>();
|
|
222
|
+
|
|
223
|
+
// Spawn responder that exits without responding
|
|
224
|
+
yield* spawn(function* () {
|
|
225
|
+
const _request = yield* useChannelRequest<string>(response.port);
|
|
226
|
+
// Exit without calling resolve or reject
|
|
227
|
+
// finally block in useChannelRequest closes port
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Requester should get an error
|
|
231
|
+
let error: Error | undefined;
|
|
232
|
+
try {
|
|
233
|
+
yield* response;
|
|
234
|
+
} catch (e) {
|
|
235
|
+
error = e as Error;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
expect(error).toBeDefined();
|
|
239
|
+
expect(error?.message).toContain("closed");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("timeout (useChannelResponse)", () => {
|
|
244
|
+
it("times out if responder is slow", function* () {
|
|
245
|
+
const response = yield* useChannelResponse<string>({
|
|
246
|
+
timeout: 50,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Spawn responder that never responds
|
|
250
|
+
yield* spawn(function* () {
|
|
251
|
+
response.port.start();
|
|
252
|
+
yield* suspend(); // Never respond
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Requester should timeout
|
|
256
|
+
let error: Error | undefined;
|
|
257
|
+
try {
|
|
258
|
+
yield* response;
|
|
259
|
+
} catch (e) {
|
|
260
|
+
error = e as Error;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
expect(error).toBeDefined();
|
|
264
|
+
expect(error?.message).toContain("timed out");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("succeeds if response arrives before timeout", function* () {
|
|
268
|
+
const response = yield* useChannelResponse<string>({
|
|
269
|
+
timeout: 1000,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Spawn responder that responds quickly
|
|
273
|
+
yield* spawn(function* () {
|
|
274
|
+
const { resolve } = yield* useChannelRequest<string>(response.port);
|
|
275
|
+
yield* resolve("fast response");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const result = yield* response;
|
|
279
|
+
expect(result).toEqual({ ok: true, value: "fast response" });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("no timeout waits indefinitely but detects close", function* () {
|
|
283
|
+
const response = yield* useChannelResponse<string>(); // No timeout
|
|
284
|
+
|
|
285
|
+
// Close port after a delay
|
|
286
|
+
yield* spawn(function* () {
|
|
287
|
+
response.port.start();
|
|
288
|
+
yield* sleep(10);
|
|
289
|
+
response.port.close();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Should error on close, not hang
|
|
293
|
+
let error: Error | undefined;
|
|
294
|
+
try {
|
|
295
|
+
yield* response;
|
|
296
|
+
} catch (e) {
|
|
297
|
+
error = e as Error;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
expect(error).toBeDefined();
|
|
301
|
+
expect(error?.message).toContain("closed");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("cancellation (useChannelRequest)", () => {
|
|
306
|
+
it("responder handles requester cancellation gracefully", function* () {
|
|
307
|
+
const channel = new MessageChannel();
|
|
308
|
+
channel.port1.start();
|
|
309
|
+
channel.port2.start();
|
|
310
|
+
|
|
311
|
+
const responderSentMessage = withResolvers<void>();
|
|
312
|
+
const responderCompleted = withResolvers<void>();
|
|
313
|
+
|
|
314
|
+
// Spawn responder using raw postMessage so we can signal at the right moment
|
|
315
|
+
yield* spawn(function* () {
|
|
316
|
+
// Send response
|
|
317
|
+
channel.port2.postMessage({ ok: true, value: "response" });
|
|
318
|
+
|
|
319
|
+
// Signal AFTER postMessage - now responder will wait for ACK
|
|
320
|
+
responderSentMessage.resolve();
|
|
321
|
+
|
|
322
|
+
// Race between ACK and close (same logic as useChannelRequest)
|
|
323
|
+
const event = yield* race([
|
|
324
|
+
once(channel.port2, "message"),
|
|
325
|
+
once(channel.port2, "close"),
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
// Should detect close, not hang waiting for ACK
|
|
329
|
+
if ((event as Event).type === "close") {
|
|
330
|
+
responderCompleted.resolve();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// If we got here, ACK was received (unexpected in this test)
|
|
335
|
+
responderCompleted.resolve();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Wait for responder to send message and start waiting for ACK
|
|
339
|
+
yield* responderSentMessage.operation;
|
|
340
|
+
|
|
341
|
+
// Close port1 (simulates requester cancellation) - no sleep needed!
|
|
342
|
+
channel.port1.close();
|
|
343
|
+
|
|
344
|
+
// Responder should complete (not hang) - race detects close
|
|
345
|
+
yield* responderCompleted.operation;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("ACK is sent for error responses", function* () {
|
|
349
|
+
const response = yield* useChannelResponse<string>();
|
|
350
|
+
|
|
351
|
+
const ackReceived = withResolvers<void>();
|
|
352
|
+
|
|
353
|
+
// Spawn responder that tracks ACK receipt
|
|
354
|
+
yield* spawn(function* () {
|
|
355
|
+
const { reject } = yield* useChannelRequest<string>(response.port);
|
|
356
|
+
yield* reject(new Error("test error"));
|
|
357
|
+
// If we get here, ACK was received (reject waits for ACK)
|
|
358
|
+
ackReceived.resolve();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Requester receives response (and sends ACK)
|
|
362
|
+
const result = yield* response;
|
|
363
|
+
expect(result.ok).toBe(false);
|
|
364
|
+
|
|
365
|
+
// Verify responder completed (meaning ACK was received)
|
|
366
|
+
yield* ackReceived.operation;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("port closes if responder exits without responding", function* () {
|
|
370
|
+
const channel = new MessageChannel();
|
|
371
|
+
channel.port1.start();
|
|
372
|
+
channel.port2.start();
|
|
373
|
+
|
|
374
|
+
const closeReceived = withResolvers<void>();
|
|
375
|
+
|
|
376
|
+
// Set up close listener before spawning responder
|
|
377
|
+
channel.port1.addEventListener("close", () => {
|
|
378
|
+
closeReceived.resolve();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Spawn responder that exits without responding
|
|
382
|
+
yield* spawn(function* () {
|
|
383
|
+
const _request = yield* useChannelRequest<string>(channel.port2);
|
|
384
|
+
// Exit without calling resolve or reject
|
|
385
|
+
// The finally block should close the port
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Wait for close event with a timeout
|
|
389
|
+
const result = yield* timebox(100, () => closeReceived.operation);
|
|
390
|
+
|
|
391
|
+
expect(result.timeout).toBe(false);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("port closes if responder throws before responding", function* () {
|
|
395
|
+
const channel = new MessageChannel();
|
|
396
|
+
channel.port1.start();
|
|
397
|
+
channel.port2.start();
|
|
398
|
+
|
|
399
|
+
const closeReceived = withResolvers<void>();
|
|
400
|
+
|
|
401
|
+
// Set up close listener before spawning responder
|
|
402
|
+
channel.port1.addEventListener("close", () => {
|
|
403
|
+
closeReceived.resolve();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Spawn responder that throws - but catch the error
|
|
407
|
+
const task = yield* spawn(function* () {
|
|
408
|
+
try {
|
|
409
|
+
const _request = yield* useChannelRequest<string>(channel.port2);
|
|
410
|
+
throw new Error("responder crashed");
|
|
411
|
+
} catch {
|
|
412
|
+
// expected
|
|
413
|
+
}
|
|
414
|
+
// finally block in useChannelRequest will close port2
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
yield* task;
|
|
418
|
+
|
|
419
|
+
// Wait for close event with a timeout
|
|
420
|
+
const result = yield* timebox(100, () => closeReceived.operation);
|
|
421
|
+
|
|
422
|
+
expect(result.timeout).toBe(false);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("requester sees close if cancelled while waiting", function* () {
|
|
426
|
+
const closeReceived = withResolvers<void>();
|
|
427
|
+
const responderReady = withResolvers<void>();
|
|
428
|
+
|
|
429
|
+
let transferredPort: MessagePort;
|
|
430
|
+
|
|
431
|
+
// Start requester in a task we can halt
|
|
432
|
+
const requesterTask = yield* spawn(function* () {
|
|
433
|
+
const response = yield* useChannelResponse<string>();
|
|
434
|
+
transferredPort = response.port;
|
|
435
|
+
|
|
436
|
+
// Signal that port is available
|
|
437
|
+
responderReady.resolve();
|
|
438
|
+
|
|
439
|
+
// Wait for response (will be cancelled)
|
|
440
|
+
return yield* response;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Wait for port to be available
|
|
444
|
+
yield* responderReady.operation;
|
|
445
|
+
|
|
446
|
+
// Set up responder with the transferred port
|
|
447
|
+
yield* spawn(function* () {
|
|
448
|
+
transferredPort.start();
|
|
449
|
+
transferredPort.addEventListener("close", () => {
|
|
450
|
+
closeReceived.resolve();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Don't send response - just wait for close
|
|
454
|
+
yield* suspend();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Cancel the requester
|
|
458
|
+
yield* requesterTask.halt();
|
|
459
|
+
|
|
460
|
+
// Verify responder saw close with timeout
|
|
461
|
+
const result = yield* timebox(100, () => closeReceived.operation);
|
|
462
|
+
|
|
463
|
+
expect(result.timeout).toBe(false);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("port closes if requester scope exits without yielding response", function* () {
|
|
467
|
+
const closeReceived = withResolvers<void>();
|
|
468
|
+
const responderReady = withResolvers<void>();
|
|
469
|
+
|
|
470
|
+
let transferredPort!: MessagePort;
|
|
471
|
+
|
|
472
|
+
// Start requester in a task that exits without yielding response
|
|
473
|
+
const requesterTask = yield* spawn(function* () {
|
|
474
|
+
const response = yield* useChannelResponse<string>();
|
|
475
|
+
transferredPort = response.port;
|
|
476
|
+
|
|
477
|
+
responderReady.resolve();
|
|
478
|
+
|
|
479
|
+
// Exit WITHOUT calling yield* response
|
|
480
|
+
// Resource cleanup should still close port1
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Wait for port to be available
|
|
484
|
+
yield* responderReady.operation;
|
|
485
|
+
|
|
486
|
+
// Set up close listener on transferred port
|
|
487
|
+
transferredPort.start();
|
|
488
|
+
transferredPort.addEventListener("close", () => {
|
|
489
|
+
closeReceived.resolve();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Wait for requester task to complete (it exits immediately)
|
|
493
|
+
yield* requesterTask;
|
|
494
|
+
|
|
495
|
+
// Verify close was received with timeout
|
|
496
|
+
const result = yield* timebox(100, () => closeReceived.operation);
|
|
497
|
+
|
|
498
|
+
expect(result.timeout).toBe(false);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe("progress streaming", () => {
|
|
503
|
+
describe("useChannelResponse.progress", () => {
|
|
504
|
+
it("receives multiple progress updates then final response", function* () {
|
|
505
|
+
const response = yield* useChannelResponse<string, number>();
|
|
506
|
+
|
|
507
|
+
// Spawn responder that sends progress updates
|
|
508
|
+
yield* spawn(function* () {
|
|
509
|
+
const request = yield* useChannelRequest<string, number>(
|
|
510
|
+
response.port,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// Send progress updates
|
|
514
|
+
yield* request.progress(1);
|
|
515
|
+
yield* request.progress(2);
|
|
516
|
+
yield* request.progress(3);
|
|
517
|
+
|
|
518
|
+
// Send final response
|
|
519
|
+
yield* request.resolve("done");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Use progress subscription
|
|
523
|
+
const subscription = yield* response.progress;
|
|
524
|
+
|
|
525
|
+
const progressValues: number[] = [];
|
|
526
|
+
let next = yield* subscription.next();
|
|
527
|
+
while (!next.done) {
|
|
528
|
+
progressValues.push(next.value);
|
|
529
|
+
next = yield* subscription.next();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
expect(progressValues).toEqual([1, 2, 3]);
|
|
533
|
+
expect(next.value).toEqual({ ok: true, value: "done" });
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("yield* response ignores progress and returns final response", function* () {
|
|
537
|
+
const response = yield* useChannelResponse<string, number>();
|
|
538
|
+
|
|
539
|
+
// Spawn responder that sends progress then response
|
|
540
|
+
yield* spawn(function* () {
|
|
541
|
+
const request = yield* useChannelRequest<string, number>(
|
|
542
|
+
response.port,
|
|
543
|
+
);
|
|
544
|
+
yield* request.progress(1);
|
|
545
|
+
yield* request.progress(2);
|
|
546
|
+
yield* request.resolve("final");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Directly yield response (ignores progress)
|
|
550
|
+
const result = yield* response;
|
|
551
|
+
expect(result).toEqual({ ok: true, value: "final" });
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("handles error response after progress", function* () {
|
|
555
|
+
const response = yield* useChannelResponse<string, number>();
|
|
556
|
+
|
|
557
|
+
yield* spawn(function* () {
|
|
558
|
+
const request = yield* useChannelRequest<string, number>(
|
|
559
|
+
response.port,
|
|
560
|
+
);
|
|
561
|
+
yield* request.progress(1);
|
|
562
|
+
yield* request.reject(new Error("failed after progress"));
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const subscription = yield* response.progress;
|
|
566
|
+
|
|
567
|
+
const progressValues: number[] = [];
|
|
568
|
+
let next = yield* subscription.next();
|
|
569
|
+
while (!next.done) {
|
|
570
|
+
progressValues.push(next.value);
|
|
571
|
+
next = yield* subscription.next();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
expect(progressValues).toEqual([1]);
|
|
575
|
+
expect(next.value.ok).toBe(false);
|
|
576
|
+
if (!next.value.ok) {
|
|
577
|
+
expect(next.value.error.message).toBe("failed after progress");
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("errors if port closes during progress", function* () {
|
|
582
|
+
const response = yield* useChannelResponse<string, number>();
|
|
583
|
+
|
|
584
|
+
// Spawn responder that sends one progress then closes
|
|
585
|
+
yield* spawn(function* () {
|
|
586
|
+
response.port.start();
|
|
587
|
+
response.port.postMessage({ type: "progress", data: 1 });
|
|
588
|
+
// Wait for progress_ack
|
|
589
|
+
yield* once(response.port, "message");
|
|
590
|
+
// Close without sending response
|
|
591
|
+
response.port.close();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const subscription = yield* response.progress;
|
|
595
|
+
|
|
596
|
+
// First progress should work
|
|
597
|
+
const first = yield* subscription.next();
|
|
598
|
+
expect(first.done).toBe(false);
|
|
599
|
+
expect(first.value).toBe(1);
|
|
600
|
+
|
|
601
|
+
// Next should error because port closed
|
|
602
|
+
let error: Error | undefined;
|
|
603
|
+
try {
|
|
604
|
+
yield* subscription.next();
|
|
605
|
+
} catch (e) {
|
|
606
|
+
error = e as Error;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
expect(error).toBeDefined();
|
|
610
|
+
expect(error?.message).toContain("closed");
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe("useChannelRequest.progress", () => {
|
|
615
|
+
it("sends progress with backpressure (waits for ACK)", function* () {
|
|
616
|
+
const channel = new MessageChannel();
|
|
617
|
+
channel.port1.start();
|
|
618
|
+
|
|
619
|
+
const progressReceived: number[] = [];
|
|
620
|
+
const acksSent = { count: 0 };
|
|
621
|
+
|
|
622
|
+
// Simulate requester on port1
|
|
623
|
+
yield* spawn(function* () {
|
|
624
|
+
while (true) {
|
|
625
|
+
const event = yield* once(channel.port1, "message");
|
|
626
|
+
const msg = (event as MessageEvent).data;
|
|
627
|
+
|
|
628
|
+
if (msg.type === "progress") {
|
|
629
|
+
progressReceived.push(msg.data);
|
|
630
|
+
// Delay ACK slightly to test backpressure
|
|
631
|
+
yield* sleep(10);
|
|
632
|
+
acksSent.count++;
|
|
633
|
+
channel.port1.postMessage({ type: "progress_ack" });
|
|
634
|
+
} else if (msg.type === "response") {
|
|
635
|
+
channel.port1.postMessage({ type: "ack" });
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// Responder on port2
|
|
642
|
+
const request = yield* useChannelRequest<string, number>(channel.port2);
|
|
643
|
+
|
|
644
|
+
// These should block until ACK received
|
|
645
|
+
yield* request.progress(10);
|
|
646
|
+
yield* request.progress(20);
|
|
647
|
+
yield* request.resolve("done");
|
|
648
|
+
|
|
649
|
+
expect(progressReceived).toEqual([10, 20]);
|
|
650
|
+
expect(acksSent.count).toBe(2);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("detects port close during progress", function* () {
|
|
654
|
+
const channel = new MessageChannel();
|
|
655
|
+
channel.port1.start();
|
|
656
|
+
|
|
657
|
+
// Simulate requester that closes after receiving progress
|
|
658
|
+
yield* spawn(function* () {
|
|
659
|
+
const event = yield* once(channel.port1, "message");
|
|
660
|
+
const msg = (event as MessageEvent).data;
|
|
661
|
+
expect(msg.type).toBe("progress");
|
|
662
|
+
// Close without sending ACK
|
|
663
|
+
channel.port1.close();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const request = yield* useChannelRequest<string, number>(channel.port2);
|
|
667
|
+
|
|
668
|
+
// progress() should detect close and exit gracefully
|
|
669
|
+
yield* request.progress(1);
|
|
670
|
+
// Should not throw, just return (requester cancelled)
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("progress blocks until worker is ready for next value", function* () {
|
|
674
|
+
// This test documents the backpressure semantics:
|
|
675
|
+
// - progress() blocks until the worker calls subscription.next()
|
|
676
|
+
// - This provides TRUE backpressure - host can't outpace worker
|
|
677
|
+
// - The ACK is sent inside next(), so it waits for worker readiness
|
|
678
|
+
|
|
679
|
+
const response = yield* useChannelResponse<string, number>();
|
|
680
|
+
const progressDurations: number[] = [];
|
|
681
|
+
const processingTime = 50;
|
|
682
|
+
|
|
683
|
+
// Responder sends progress and measures how long each takes
|
|
684
|
+
yield* spawn(function* () {
|
|
685
|
+
const request = yield* useChannelRequest<string, number>(
|
|
686
|
+
response.port,
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// First progress - should be fast (worker is waiting)
|
|
690
|
+
const start1 = Date.now();
|
|
691
|
+
yield* request.progress(1);
|
|
692
|
+
progressDurations.push(Date.now() - start1);
|
|
693
|
+
|
|
694
|
+
// Second progress - should wait ~50ms for worker processing
|
|
695
|
+
const start2 = Date.now();
|
|
696
|
+
yield* request.progress(2);
|
|
697
|
+
progressDurations.push(Date.now() - start2);
|
|
698
|
+
|
|
699
|
+
yield* request.resolve("done");
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Requester receives progress and processes slowly
|
|
703
|
+
const subscription = yield* response.progress;
|
|
704
|
+
|
|
705
|
+
// Get first progress (responder is waiting)
|
|
706
|
+
let next = yield* subscription.next();
|
|
707
|
+
expect(next.done).toBe(false);
|
|
708
|
+
expect(next.value).toBe(1);
|
|
709
|
+
|
|
710
|
+
// Simulate slow processing before requesting next
|
|
711
|
+
yield* sleep(processingTime);
|
|
712
|
+
|
|
713
|
+
// Get second progress
|
|
714
|
+
next = yield* subscription.next();
|
|
715
|
+
expect(next.done).toBe(false);
|
|
716
|
+
expect(next.value).toBe(2);
|
|
717
|
+
|
|
718
|
+
// Get final response
|
|
719
|
+
next = yield* subscription.next();
|
|
720
|
+
expect(next.done).toBe(true);
|
|
721
|
+
expect(next.value).toEqual({ ok: true, value: "done" });
|
|
722
|
+
|
|
723
|
+
// First progress was fast (worker was already waiting)
|
|
724
|
+
// Use generous tolerance for CI environments
|
|
725
|
+
expect(progressDurations[0]).toBeLessThan(50);
|
|
726
|
+
|
|
727
|
+
// Second progress waited for worker to finish processing
|
|
728
|
+
// (host blocked until worker called next() again)
|
|
729
|
+
// Use generous tolerance (processingTime - 20) for CI environments
|
|
730
|
+
expect(progressDurations[1]).toBeGreaterThanOrEqual(
|
|
731
|
+
processingTime - 20,
|
|
732
|
+
);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
describe("progress round-trip", () => {
|
|
737
|
+
it("preserves order of multiple progress updates", function* () {
|
|
738
|
+
const response = yield* useChannelResponse<string, string>();
|
|
739
|
+
|
|
740
|
+
const expectedProgress = ["a", "b", "c", "d", "e"];
|
|
741
|
+
|
|
742
|
+
yield* spawn(function* () {
|
|
743
|
+
const request = yield* useChannelRequest<string, string>(
|
|
744
|
+
response.port,
|
|
745
|
+
);
|
|
746
|
+
for (const p of expectedProgress) {
|
|
747
|
+
yield* request.progress(p);
|
|
748
|
+
}
|
|
749
|
+
yield* request.resolve("complete");
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const subscription = yield* response.progress;
|
|
753
|
+
const received: string[] = [];
|
|
754
|
+
|
|
755
|
+
let next = yield* subscription.next();
|
|
756
|
+
while (!next.done) {
|
|
757
|
+
received.push(next.value);
|
|
758
|
+
next = yield* subscription.next();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
expect(received).toEqual(expectedProgress);
|
|
762
|
+
expect(next.value).toEqual({ ok: true, value: "complete" });
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("handles complex progress data", function* () {
|
|
766
|
+
interface ProgressData {
|
|
767
|
+
step: number;
|
|
768
|
+
message: string;
|
|
769
|
+
details?: { items: string[] };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const response = yield* useChannelResponse<
|
|
773
|
+
{ result: string },
|
|
774
|
+
ProgressData
|
|
775
|
+
>();
|
|
776
|
+
|
|
777
|
+
const progress1: ProgressData = { step: 1, message: "Starting" };
|
|
778
|
+
const progress2: ProgressData = {
|
|
779
|
+
step: 2,
|
|
780
|
+
message: "Processing",
|
|
781
|
+
details: { items: ["x", "y"] },
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
yield* spawn(function* () {
|
|
785
|
+
const request = yield* useChannelRequest<
|
|
786
|
+
{ result: string },
|
|
787
|
+
ProgressData
|
|
788
|
+
>(response.port);
|
|
789
|
+
yield* request.progress(progress1);
|
|
790
|
+
yield* request.progress(progress2);
|
|
791
|
+
yield* request.resolve({ result: "success" });
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const subscription = yield* response.progress;
|
|
795
|
+
const received: ProgressData[] = [];
|
|
796
|
+
|
|
797
|
+
let next = yield* subscription.next();
|
|
798
|
+
while (!next.done) {
|
|
799
|
+
received.push(next.value);
|
|
800
|
+
next = yield* subscription.next();
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
expect(received).toEqual([progress1, progress2]);
|
|
804
|
+
expect(next.value).toEqual({ ok: true, value: { result: "success" } });
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("handles zero progress updates", function* () {
|
|
808
|
+
const response = yield* useChannelResponse<string, number>();
|
|
809
|
+
|
|
810
|
+
yield* spawn(function* () {
|
|
811
|
+
const request = yield* useChannelRequest<string, number>(
|
|
812
|
+
response.port,
|
|
813
|
+
);
|
|
814
|
+
// No progress, just resolve
|
|
815
|
+
yield* request.resolve("immediate");
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const subscription = yield* response.progress;
|
|
819
|
+
const next = yield* subscription.next();
|
|
820
|
+
|
|
821
|
+
// Should immediately return done with the response
|
|
822
|
+
expect(next.done).toBe(true);
|
|
823
|
+
expect(next.value).toEqual({ ok: true, value: "immediate" });
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("requester cancellation during progress stops responder", function* () {
|
|
827
|
+
const responderExited = withResolvers<void>();
|
|
828
|
+
const firstProgressReceived = withResolvers<void>();
|
|
829
|
+
const portReady = withResolvers<MessagePort>();
|
|
830
|
+
|
|
831
|
+
// Requester task we can cancel
|
|
832
|
+
const requesterTask = yield* spawn(function* () {
|
|
833
|
+
const response = yield* useChannelResponse<string, number>();
|
|
834
|
+
portReady.resolve(response.port);
|
|
835
|
+
|
|
836
|
+
const subscription = yield* response.progress;
|
|
837
|
+
// Get first progress
|
|
838
|
+
const first = yield* subscription.next();
|
|
839
|
+
expect(first.done).toBe(false);
|
|
840
|
+
firstProgressReceived.resolve();
|
|
841
|
+
// Then hang waiting for more
|
|
842
|
+
yield* subscription.next();
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
// Wait for port to be ready
|
|
846
|
+
const transferredPort = yield* portReady.operation;
|
|
847
|
+
|
|
848
|
+
// Responder
|
|
849
|
+
yield* spawn(function* () {
|
|
850
|
+
const request = yield* useChannelRequest<string, number>(
|
|
851
|
+
transferredPort,
|
|
852
|
+
);
|
|
853
|
+
yield* request.progress(1);
|
|
854
|
+
// This should detect close when requester is cancelled
|
|
855
|
+
yield* request.progress(2);
|
|
856
|
+
responderExited.resolve();
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// Wait for first progress to be received
|
|
860
|
+
yield* firstProgressReceived.operation;
|
|
861
|
+
|
|
862
|
+
// Cancel requester
|
|
863
|
+
yield* requesterTask.halt();
|
|
864
|
+
|
|
865
|
+
// Responder should exit gracefully
|
|
866
|
+
const result = yield* timebox(100, () => responderExited.operation);
|
|
867
|
+
|
|
868
|
+
expect(result.timeout).toBe(false);
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
describe("progress with timeout", () => {
|
|
873
|
+
it("timeout applies to entire progress+response exchange", function* () {
|
|
874
|
+
const response = yield* useChannelResponse<string, number>({
|
|
875
|
+
timeout: 50,
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// Responder that sends progress but never responds
|
|
879
|
+
yield* spawn(function* () {
|
|
880
|
+
response.port.start();
|
|
881
|
+
response.port.postMessage({ type: "progress", data: 1 });
|
|
882
|
+
// Never send response
|
|
883
|
+
yield* suspend();
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
let error: Error | undefined;
|
|
887
|
+
try {
|
|
888
|
+
const subscription = yield* response.progress;
|
|
889
|
+
// First progress works
|
|
890
|
+
yield* subscription.next();
|
|
891
|
+
// But waiting for more times out
|
|
892
|
+
yield* subscription.next();
|
|
893
|
+
} catch (e) {
|
|
894
|
+
error = e as Error;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
expect(error).toBeDefined();
|
|
898
|
+
expect(error?.message).toContain("timed out");
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
});
|