effect-browser 0.3.0-beta.13 → 0.3.0-beta.14

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.
Files changed (52) hide show
  1. package/README.md +50 -19
  2. package/dist/{Browser-QiT6Cdn_.mjs → Browser-BBSySyjT.mjs} +2 -2
  3. package/dist/{Browser-QiT6Cdn_.mjs.map → Browser-BBSySyjT.mjs.map} +1 -1
  4. package/dist/Browser.d.mts +1 -1
  5. package/dist/Browser.mjs +1 -1
  6. package/dist/Cdp.mjs +1 -1
  7. package/dist/Chromium.mjs +1 -1
  8. package/dist/Expect.d.mts +1 -1
  9. package/dist/Expect.mjs +1 -1
  10. package/dist/{Frame-Di2PxyF7.d.mts → Frame-DkNdjjjr.d.mts} +3 -5
  11. package/dist/Frame.d.mts +1 -1
  12. package/dist/Frame.mjs +2 -4
  13. package/dist/Frame.mjs.map +1 -1
  14. package/dist/Moment.d.mts +1 -1
  15. package/dist/Moment.mjs +7 -3
  16. package/dist/Moment.mjs.map +1 -1
  17. package/dist/{Page-B7X6DQUn.d.mts → Page-DL9eylp3.d.mts} +2 -2
  18. package/dist/Page.d.mts +1 -1
  19. package/dist/{Plan-BS0jpENY.d.mts → Plan-6As6vcwT.d.mts} +3 -3
  20. package/dist/{Plan-Bl2NM1D5.mjs → Plan-CXA2BW-G.mjs} +4 -6
  21. package/dist/Plan-CXA2BW-G.mjs.map +1 -0
  22. package/dist/Plan.d.mts +1 -1
  23. package/dist/Plan.mjs +1 -1
  24. package/dist/Policy.d.mts +1 -1
  25. package/dist/Presentation.d.mts +1 -1
  26. package/dist/Presentation.mjs +1 -1
  27. package/dist/Stage.d.mts +5 -4
  28. package/dist/Stage.mjs +53 -20
  29. package/dist/Stage.mjs.map +1 -1
  30. package/dist/{Supervisor-TNKNOVzu.mjs → Supervisor-BE2sGHBK.mjs} +67 -41
  31. package/dist/Supervisor-BE2sGHBK.mjs.map +1 -0
  32. package/dist/{Supervisor-CYWFK3vM.d.mts → Supervisor-W60pZWHh.d.mts} +24 -8
  33. package/dist/Supervisor.d.mts +2 -2
  34. package/dist/Supervisor.mjs +1 -1
  35. package/dist/index.d.mts +4 -4
  36. package/dist/index.mjs +3 -3
  37. package/dist/{page-ZxMhw16l.mjs → page-B92QnRfT.mjs} +196 -166
  38. package/dist/page-B92QnRfT.mjs.map +1 -0
  39. package/package.json +1 -1
  40. package/src/Frame.ts +2 -4
  41. package/src/Moment.ts +14 -3
  42. package/src/Plan.ts +7 -6
  43. package/src/Stage.ts +61 -21
  44. package/src/Supervisor.ts +69 -37
  45. package/src/internal/pictures/capture.ts +172 -188
  46. package/src/internal/pictures/pictures.ts +50 -73
  47. package/src/internal/pictures/quiescence.ts +94 -0
  48. package/src/internal/reading/ready.ts +9 -3
  49. package/src/internal/supervisor/lifecycle.ts +27 -12
  50. package/dist/Plan-Bl2NM1D5.mjs.map +0 -1
  51. package/dist/Supervisor-TNKNOVzu.mjs.map +0 -1
  52. package/dist/page-ZxMhw16l.mjs.map +0 -1
@@ -20,6 +20,7 @@ import { BrowserPaint, CaptureStats, Frame, type ScreencastOptions } from "../..
20
20
  import { undispatched } from "../page/context.ts";
21
21
  import { type Estimate, toHostTime, uncertaintyAt } from "./clock.ts";
22
22
  import { tally } from "./counts.ts";
23
+ import * as Quiescence from "./quiescence.ts";
23
24
  import type { NativeFrame, Transport } from "./transport.ts";
24
25
 
25
26
  export { countsKept } from "./counts.ts";
@@ -76,11 +77,9 @@ interface Generation {
76
77
  /** The native capture's settings; every reader of this generation shares them. */
77
78
  readonly quality: number;
78
79
  readonly size: Size | null;
79
- readonly onFrame: (frame: NativeFrame) => void;
80
- accepting: boolean;
80
+ phase: "Capturing" | "Paused" | "Stopped";
81
81
  predecessor: number | undefined;
82
82
  failure: BrowserError | undefined;
83
- stopReply: Promise<void> | undefined;
84
83
  }
85
84
 
86
85
  export interface Controller {
@@ -109,15 +108,7 @@ export interface Controller {
109
108
  /** Run a clipped or scaled picture of the page, keeping what it draws out of the capture. */
110
109
  readonly excluding: <A, E, R>(
111
110
  picture: Effect.Effect<A, E, R>,
112
- resized?: boolean,
113
- ) => Effect.Effect<A, E, R>;
114
- }
115
-
116
- /** When the library took a clipped picture: from its call to its reply, in host milliseconds. */
117
- interface Excluded {
118
- readonly from: number;
119
- until: number;
120
- readonly resized: boolean;
111
+ ) => Effect.Effect<A, E | BrowserError, R>;
121
112
  }
122
113
 
123
114
  const replyCapacity = 32;
@@ -137,56 +128,6 @@ const sameShape = (image: Size, device: Size) =>
137
128
  // the page's own.
138
129
  const outlasting = Duration.seconds(1);
139
130
 
140
- // No frame painted during a picture arrives this long after it.
141
- const excludedFor = Duration.seconds(10);
142
-
143
- // Every frame of a crop is painted before Chromium answers the picture, yet each test of it can
144
- // land past the reply: its stamp, because Chromium stamps frames on another clock than the one the
145
- // calibration reads (2 to 3 ms late on an idle machine here), and its arrival, because a frame is
146
- // encoded after it is painted (3 to 6 ms). A frame is kept only when both land this long after.
147
- const settling = Duration.millis(50);
148
-
149
- /**
150
- * Chromium draws the library's own clipped pictures into the running screencast. Where Playwright
151
- * emulates the viewport they keep the page's device size, and its shape when the crop has the
152
- * viewport's proportions, so nothing in a frame tells them apart: a frame painted, or arriving,
153
- * while one is taken is left out, the page's own included. A crop on the page's own session is
154
- * drawn at the crop's size, so there only a frame of another size than the page's is left out.
155
- */
156
- const pictureWindows = (now: () => number) => {
157
- let windows: ReadonlyArray<Excluded> = [];
158
-
159
- return {
160
- drawn: ({ hostTime, timing, receivedAt }: Frame, foreign: boolean) =>
161
- windows.some((window) => {
162
- const until = window.until + Duration.toMillis(settling);
163
-
164
- return (
165
- (foreign || !window.resized) &&
166
- ((hostTime + timing.uncertaintyMillis >= window.from &&
167
- hostTime - timing.uncertaintyMillis <= until) ||
168
- (receivedAt >= window.from && receivedAt <= until))
169
- );
170
- }),
171
- excluding: <A, E, R>(picture: Effect.Effect<A, E, R>, resized = false) =>
172
- Effect.acquireUseRelease(
173
- Effect.sync(() => {
174
- const window = { from: now(), until: Number.POSITIVE_INFINITY, resized };
175
- const since = window.from - Duration.toMillis(excludedFor);
176
-
177
- windows = [...windows.filter((kept) => kept.until > since), window];
178
-
179
- return window;
180
- }),
181
- () => picture,
182
- (window) =>
183
- Effect.sync(() => {
184
- window.until = now();
185
- }),
186
- ),
187
- };
188
- };
189
-
190
131
  /**
191
132
  * A native frame as readers get it: its picture's size, else the capture's or its device's, and
192
133
  * its paint mapped by the browser's newest estimate, which may be older than the frame and says
@@ -331,7 +272,6 @@ const sizeFilter = (
331
272
  expected = viewport;
332
273
  },
333
274
  /** Whether a frame of this device size shows the page, as far as the capture knows. */
334
- expects: (device: Size) => expected !== null && sameSize(device, expected),
335
275
  offer: (frame: Frame, timestamp: number, image: Size | undefined, device: Size) => {
336
276
  if (image !== undefined && !sameShape(image, device)) {
337
277
  drop(1);
@@ -426,7 +366,7 @@ const isCurrent = (
426
366
  latest: Option.Option<Frame>,
427
367
  ) =>
428
368
  generation !== undefined &&
429
- generation.accepting &&
369
+ generation.phase === "Capturing" &&
430
370
  generation.failure === undefined &&
431
371
  Option.getOrUndefined(latest) === frame &&
432
372
  generation.transport.frameTag().document === frame.document;
@@ -460,6 +400,63 @@ const frames = (
460
400
  );
461
401
  };
462
402
 
403
+ const renewals = Effect.fnUntraced(function* (options: Options) {
404
+ const renewing = yield* Queue.sliding<void>(1);
405
+
406
+ yield* Queue.take(renewing).pipe(
407
+ Effect.andThen(options.renew),
408
+ Effect.andThen(Effect.sleep(Duration.seconds(1))),
409
+ Effect.provideService(Clock.Clock, options.clock),
410
+ Effect.forever,
411
+ Effect.forkScoped,
412
+ );
413
+
414
+ return renewing;
415
+ });
416
+
417
+ const receive =
418
+ (
419
+ options: Options,
420
+ current: Generation,
421
+ replies: Set<Promise<void>>,
422
+ counts: ReturnType<typeof tally>,
423
+ sizes: Effect.Success<ReturnType<typeof sizeFilter>>,
424
+ renewing: Queue.Queue<void>,
425
+ fail: (current: Generation, error: BrowserError) => void,
426
+ ) =>
427
+ (native: NativeFrame) => {
428
+ if (current.phase === "Stopped") return;
429
+ counts.count("received");
430
+ if (replies.size >= replyCapacity) {
431
+ fail(current, options.error(new Error("screencast acknowledgement capacity exceeded")));
432
+
433
+ return;
434
+ }
435
+
436
+ const reply = current.transport
437
+ .acknowledge(native.sessionId)
438
+ .then(
439
+ () => undefined,
440
+ (cause: unknown) => {
441
+ if (current.phase !== "Stopped") fail(current, options.error(cause));
442
+ },
443
+ )
444
+ .then(() => void replies.delete(reply));
445
+
446
+ replies.add(reply);
447
+ const browserSeconds = native.metadata.timestamp;
448
+ const timestamp = browserSeconds === undefined ? Number.NaN : browserSeconds * 1000;
449
+
450
+ if (!Number.isFinite(timestamp)) return counts.count("missingTimestamp");
451
+ if (current.predecessor !== undefined && timestamp <= current.predecessor)
452
+ return counts.count("late");
453
+ Queue.offerUnsafe(renewing, undefined);
454
+ const { frame, image, device } = timed(options, native, timestamp, current);
455
+
456
+ if (current.phase === "Paused") return counts.count("duringPictures");
457
+ sizes.offer(frame, timestamp, image, device);
458
+ };
459
+
463
460
  export const make = (options: Options) =>
464
461
  Effect.gen(function* () {
465
462
  const lock = yield* Semaphore.make(1);
@@ -468,9 +465,8 @@ export const make = (options: Options) =>
468
465
  // Done as the page's scope closes, once the page has gone and its browser's loss, if any, is
469
466
  // known.
470
467
  const closed = yield* Deferred.make<void>();
471
- // The latest stop whose reply has not settled. A new capture waits for it rather than
472
- // assuming it was lost; it is never submitted again.
473
- let unsettledStop: Promise<void> | undefined;
468
+ const pageScope = yield* Effect.scope;
469
+ const isClosed = () => pageScope.state._tag === "Closed" || Deferred.isDoneUnsafe(closed);
474
470
  let latest = Option.none<Frame>();
475
471
  let history: ReadonlyArray<Frame> = [];
476
472
  const keepMillis = Duration.toMillis(options.frameHistory);
@@ -478,7 +474,6 @@ export const make = (options: Options) =>
478
474
  const counts = tally(now);
479
475
  // Frames delivered, which number them so that a reader can tell what it missed.
480
476
  let delivered = 0;
481
- const pictures = pictureWindows(now);
482
477
 
483
478
  const current = (frame: Frame) => isCurrent(frame, generation, latest);
484
479
 
@@ -506,75 +501,105 @@ export const make = (options: Options) =>
506
501
 
507
502
  // On air a capture runs for hours, and its frames are timed by the browser's newest estimate,
508
503
  // so arriving frames renew it: a check at most each second, a measurement once it is due.
509
- const renewing = yield* Queue.sliding<void>(1);
510
-
511
- yield* Queue.take(renewing).pipe(
512
- Effect.andThen(options.renew),
513
- Effect.andThen(Effect.sleep(Duration.seconds(1))),
514
- Effect.provideService(Clock.Clock, options.clock),
515
- Effect.forever,
516
- Effect.forkScoped,
517
- );
504
+ const renewing = yield* renewals(options);
518
505
 
519
506
  const notifyFailure = (current: Generation, failure: BrowserError) => {
520
507
  if (current.failure !== undefined) return;
521
508
  current.failure = failure;
522
509
  for (const subscriber of current.subscribers)
523
510
  Queue.offerUnsafe(subscriber, { _tag: "Failure", error: failure });
511
+ stop(current);
524
512
  };
525
513
 
526
- const stop = (current: Generation) => {
527
- if (!current.accepting) return;
528
- current.accepting = false;
529
- sizes.end();
530
- current.unlisten();
531
-
532
- // Attach both handlers in the callback turn. Even a caller that leaves immediately never
533
- // abandons a rejected native reply, and an uncertain stop is never submitted twice.
534
- const reply = current.transport.stop().then(
535
- () => undefined,
536
- (cause: unknown) => {
537
- notifyFailure(current, options.error(cause));
538
- },
514
+ const failed = (current: Generation) => (cause: unknown) =>
515
+ notifyFailure(current, options.error(cause));
516
+
517
+ const stopNative = (current: Generation) =>
518
+ pictures.track("screencast stop", current.transport.stop(), failed(current));
519
+
520
+ const startNative = (current: Generation) =>
521
+ pictures.track(
522
+ "screencast start",
523
+ current.transport.start({
524
+ format: "jpeg",
525
+ quality: current.quality,
526
+ ...(current.size === null
527
+ ? {}
528
+ : { maxWidth: current.size.width, maxHeight: current.size.height }),
529
+ }),
530
+ failed(current),
539
531
  );
540
532
 
541
- current.stopReply = reply;
542
- unsettledStop = reply;
543
- void reply.then(() => {
544
- if (unsettledStop === reply) unsettledStop = undefined;
545
- });
546
- };
533
+ const stop = (current: Generation) => {
534
+ if (current.phase === "Stopped") return;
535
+ const paused = current.phase === "Paused";
547
536
 
548
- const fail = (current: Generation, failure: BrowserError) => {
549
- notifyFailure(current, failure);
550
- stop(current);
537
+ current.phase = "Stopped";
538
+ sizes.end();
539
+ current.unlisten();
540
+ // A paused capture already owns its stop, including a reply still in flight.
541
+ if (!paused) void stopNative(current);
551
542
  };
552
543
 
553
544
  const gone = () => options.error(new Error("Target page has been closed"));
554
- const stopDeadline = () => options.error(new Error("screencast stop exceeded its deadline"));
555
545
  const why = told(closed, gone, options.clock);
556
546
 
557
- // A stop's reply, awaited for at most `deadline`; `late` is what a stop past it means.
558
- const settled = <E>(reply: () => Promise<void> | undefined, late: Effect.Effect<void, E>) =>
559
- Effect.suspend(() => {
560
- const pending = reply();
561
-
562
- return pending === undefined
563
- ? Effect.void
564
- : Effect.promise(() => pending).pipe(
565
- Effect.interruptible,
566
- Effect.timeoutOrElse({ duration: deadline, orElse: () => late }),
567
- );
568
- }).pipe(Effect.provideService(Clock.Clock, options.clock));
569
-
570
547
  const awaitStop = (current: Generation) =>
571
- settled(
572
- () => current.stopReply,
573
- Effect.sync(() => notifyFailure(current, stopDeadline())),
548
+ pictures
549
+ .wait("screencast stop")
550
+ .pipe(Effect.catch((error) => Effect.sync(() => notifyFailure(current, error))));
551
+
552
+ const healthy = (current: Generation) =>
553
+ Effect.suspend(() =>
554
+ current.failure === undefined ? Effect.void : Effect.fail(current.failure),
574
555
  );
575
556
 
576
- // A late stop only delays the next capture; once its reply settles, capture can start again.
577
- const awaitPreviousStop = settled(() => unsettledStop, Effect.fail(stopDeadline()));
557
+ const pictures = Quiescence.make({
558
+ clock: options.clock,
559
+ error: options.error,
560
+ closed: isClosed,
561
+ current: () => generation,
562
+ fail: (current, error) => {
563
+ if (current !== undefined) notifyFailure(current, error);
564
+ },
565
+ pause: (current): Effect.Effect<void, BrowserError> =>
566
+ Effect.gen(function* () {
567
+ if (current?.phase !== "Capturing" || current.failure !== undefined) return;
568
+ current.phase = "Paused";
569
+ latest = Option.none();
570
+ sizes.end();
571
+ void stopNative(current);
572
+ yield* pictures
573
+ .wait("screencast stop")
574
+ .pipe(Effect.tapError((error) => Effect.sync(() => notifyFailure(current, error))));
575
+ yield* healthy(current);
576
+ }),
577
+ resume: (current): Effect.Effect<void, BrowserError> =>
578
+ Effect.gen(function* () {
579
+ const resumable = () => {
580
+ if (current === undefined || generation !== current) return false;
581
+ if (current.subscribers.size === 0) {
582
+ stop(current);
583
+ generation = undefined;
584
+
585
+ return false;
586
+ }
587
+
588
+ return current.phase !== "Stopped" && current.failure === undefined && !isClosed();
589
+ };
590
+
591
+ if (current === undefined || !resumable()) return;
592
+ const viewport = yield* readViewport(options);
593
+
594
+ if (!resumable()) return;
595
+ sizes.restart(viewport);
596
+ // Its only quiet-page frame can precede the native start acknowledgement.
597
+ current.phase = "Capturing";
598
+ void startNative(current);
599
+ yield* pictures.wait("screencast start");
600
+ yield* healthy(current);
601
+ }),
602
+ });
578
603
 
579
604
  // A reader leaves; the last one stops its generation.
580
605
  const leave = (current: Generation, subscription: Queue.Queue<Envelope>) =>
@@ -590,67 +615,29 @@ export const make = (options: Options) =>
590
615
  const acquire = (screencast: ScreencastOptions) =>
591
616
  lock.withPermits(1)(
592
617
  Effect.gen(function* () {
593
- if (Deferred.isDoneUnsafe(closed)) return yield* gone();
618
+ if (isClosed()) return yield* gone();
594
619
  let current = generation;
595
620
  const starting = current === undefined;
596
621
 
597
622
  if (current === undefined) {
598
- yield* awaitPreviousStop;
623
+ yield* pictures.idle;
599
624
  const [transport, { calibration, viewport }] = yield* prepare(options, screencast);
600
625
  const size = screencast.size ?? viewport;
601
626
 
602
- if (Deferred.isDoneUnsafe(closed)) return yield* gone();
627
+ if (isClosed()) return yield* gone();
603
628
 
604
- const created: Generation = {
629
+ current = {
605
630
  subscribers: new Set(),
606
631
  transport,
607
632
  unlisten: () => undefined,
608
633
  calibration,
609
634
  quality: screencast.quality ?? 80,
610
635
  size,
611
- accepting: true,
636
+ phase: "Capturing",
612
637
  predecessor: undefined,
613
638
  failure: undefined,
614
- stopReply: undefined,
615
- onFrame: (native) => {
616
- if (!created.accepting) return;
617
- counts.count("received");
618
- if (replies.size >= replyCapacity) {
619
- fail(
620
- created,
621
- options.error(new Error("screencast acknowledgement capacity exceeded")),
622
- );
623
-
624
- return;
625
- }
626
-
627
- const reply = created.transport.acknowledge(native.sessionId).then(
628
- () => {
629
- replies.delete(reply);
630
- },
631
- (cause: unknown) => {
632
- replies.delete(reply);
633
- if (created.accepting) fail(created, options.error(cause));
634
- },
635
- );
636
-
637
- replies.add(reply);
638
- const browserSeconds = native.metadata.timestamp;
639
- const timestamp = browserSeconds === undefined ? Number.NaN : browserSeconds * 1000;
640
-
641
- if (!Number.isFinite(timestamp)) return counts.count("missingTimestamp");
642
- if (created.predecessor !== undefined && timestamp <= created.predecessor)
643
- return counts.count("late");
644
- Queue.offerUnsafe(renewing, undefined);
645
- const { frame, image, device } = timed(options, native, timestamp, created);
646
-
647
- if (pictures.drawn(frame, !sizes.expects(device)))
648
- return counts.count("duringPictures");
649
- sizes.offer(frame, timestamp, image, device);
650
- },
651
639
  };
652
640
 
653
- current = created;
654
641
  generation = current;
655
642
  latest = Option.none();
656
643
  sizes.restart(viewport);
@@ -683,27 +670,19 @@ export const make = (options: Options) =>
683
670
  const frame = currentFrame(current, latest);
684
671
 
685
672
  if (starting) {
686
- const { transport, quality, size } = current;
687
673
  const listening = current;
688
674
 
689
- current.unlisten = transport.listen({
690
- frame: current.onFrame,
691
- lost: (error) => fail(listening, error),
675
+ current.unlisten = listening.transport.listen({
676
+ frame: receive(options, current, replies, counts, sizes, renewing, notifyFailure),
677
+ lost: (error) => notifyFailure(listening, error),
692
678
  });
693
679
 
694
- const screencast = Effect.tryPromise({
695
- try: () =>
696
- transport.start({
697
- format: "jpeg",
698
- quality,
699
- ...(size === null ? {} : { maxWidth: size.width, maxHeight: size.height }),
700
- }),
701
- catch: options.error,
702
- });
680
+ const screencast = Effect.promise(() => startNative(listening)).pipe(
681
+ Effect.andThen(healthy(listening)),
682
+ );
703
683
 
704
- const started = yield* Effect.all([options.watch, screencast], {
705
- concurrency: 2,
706
- }).pipe(
684
+ const started = yield* options.watch.pipe(
685
+ Effect.zip(screencast, { concurrent: true }),
707
686
  Effect.interruptible,
708
687
  Effect.timeoutOrElse({
709
688
  duration: deadline,
@@ -727,7 +706,9 @@ export const make = (options: Options) =>
727
706
 
728
707
  const open: Controller["open"] = (screencast = {}) =>
729
708
  Effect.acquireRelease(acquire(screencast), ({ current, subscription }) =>
730
- lock.withPermits(1)(leave(current, subscription)),
709
+ Effect.sync(() => current.subscribers.delete(subscription)).pipe(
710
+ Effect.andThen(lock.withPermits(1)(leave(current, subscription))),
711
+ ),
731
712
  ).pipe(
732
713
  Effect.map((lease) => ({
733
714
  current: lease.frame,
@@ -739,16 +720,19 @@ export const make = (options: Options) =>
739
720
 
740
721
  // The page's scope closes once the page has gone, when its browser's loss, if any, is known.
741
722
  yield* Effect.addFinalizer(() =>
742
- lock.withPermits(1)(
743
- Effect.suspend(() => {
744
- const current = generation;
723
+ Effect.sync(() => Deferred.doneUnsafe(closed, Exit.void)).pipe(
724
+ Effect.andThen(
725
+ lock.withPermits(1)(
726
+ Effect.suspend(() => {
727
+ const current = generation;
745
728
 
746
- Deferred.doneUnsafe(closed, Exit.void);
747
- if (current === undefined) return Effect.void;
748
- fail(current, gone());
729
+ if (current === undefined) return Effect.void;
730
+ notifyFailure(current, gone());
749
731
 
750
- return awaitStop(current);
751
- }),
732
+ return awaitStop(current);
733
+ }),
734
+ ),
735
+ ),
752
736
  ),
753
737
  );
754
738
 
@@ -758,7 +742,7 @@ export const make = (options: Options) =>
758
742
  Stream.unwrap(Effect.map(open(options), ({ stream }) => stream)),
759
743
  latest: Effect.sync(() => latest),
760
744
  recent: Effect.sync(() => history),
761
- active: Effect.sync(() => generation?.accepting === true),
745
+ active: Effect.sync(() => generation !== undefined && generation.phase !== "Stopped"),
762
746
  sync: Effect.suspend(() => synced(generation, why, options.error)),
763
747
  current,
764
748
  confirm: confirmation(
@@ -767,7 +751,7 @@ export const make = (options: Options) =>
767
751
  why,
768
752
  options.error,
769
753
  ),
770
- excluding: pictures.excluding,
754
+ excluding: (picture) => lock.withPermits(1)(pictures.excluding(picture)),
771
755
  stats: (windowMillis?: number) =>
772
756
  Effect.sync(
773
757
  () => new CaptureStats({ ...counts.read(windowMillis), ackBacklog: replies.size }),