@effectionx/worker 0.4.2 → 0.5.0

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.
@@ -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
+ });