@push.rocks/smartbrowser 4.0.2 → 4.1.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.
@@ -6,6 +6,7 @@ import type {
6
6
  ILiveBrowserMouseInput,
7
7
  ILiveBrowserState,
8
8
  ILiveBrowserViewport,
9
+ ILiveBrowserWheelInput,
9
10
  TLiveBrowserEvent,
10
11
  } from '@push.rocks/smartpuppeteer';
11
12
 
@@ -13,9 +14,15 @@ import type {
13
14
  ILiveBrowserCanvasError,
14
15
  ILiveBrowserCanvasOperationOptions,
15
16
  ILiveBrowserCanvasRendererOptions,
17
+ ILiveBrowserCanvasRendererStatistics,
16
18
  } from './interfaces.livebrowsercanvas.js';
17
19
 
18
20
  const maxInputQueueLength = 128;
21
+ const maxInFlightInputCommands = 4;
22
+ const maxQueuedCoalescableCommands = 32;
23
+ const maxWheelDelta = 1000000;
24
+ const animationFrameFallbackMs = 16;
25
+ const resizeDebounceMs = 100;
19
26
  const maxPendingAcknowledgements = 16;
20
27
  const maxInputReleaseAttempts = 2;
21
28
  const maxFrameDimension = 12288;
@@ -45,17 +52,63 @@ interface IDisplayedFrame extends ILiveBrowserInputBase {
45
52
  }
46
53
 
47
54
  type TInputCommandKind = 'key' | 'mouse' | 'mouseMove' | 'text' | 'wheel';
55
+ type TCoalescableInputCommandKind = 'mouseMove' | 'wheel';
48
56
 
49
57
  interface IInputCommand {
50
58
  kind: TInputCommandKind;
51
59
  runEpoch: number;
52
60
  execute: (optionsArg: ILiveBrowserCanvasOperationOptions) => Promise<void>;
61
+ /** Mutable merged wheel payload that execute() reads at dispatch time. */
62
+ wheelInput?: ILiveBrowserWheelInput;
53
63
  onAttempt?: () => void;
54
64
  onSuccess?: () => void;
55
65
  resolve: () => void;
56
66
  reject: (errorArg: unknown) => void;
57
67
  }
58
68
 
69
+ interface IPendingWheel extends ILiveBrowserWheelInput {
70
+ runEpoch: number;
71
+ }
72
+
73
+ interface IPendingDraw {
74
+ bitmap: ImageBitmap;
75
+ frame: ILiveBrowserFrame;
76
+ runEpoch: number;
77
+ }
78
+
79
+ interface IScheduledFrame {
80
+ animationFrameId?: number;
81
+ timeoutId?: ReturnType<typeof setTimeout>;
82
+ }
83
+
84
+ const isCoalescableInputCommand = (
85
+ kindArg: TInputCommandKind,
86
+ ): kindArg is TCoalescableInputCommandKind => kindArg === 'mouseMove' || kindArg === 'wheel';
87
+
88
+ const clampWheelDelta = (valueArg: number): number => (
89
+ Math.max(-maxWheelDelta, Math.min(maxWheelDelta, valueArg))
90
+ );
91
+
92
+ const mergeWheelInput = (
93
+ targetArg: ILiveBrowserWheelInput,
94
+ sourceArg: ILiveBrowserWheelInput,
95
+ ): void => {
96
+ targetArg.x = sourceArg.x;
97
+ targetArg.y = sourceArg.y;
98
+ targetArg.deltaX = clampWheelDelta(targetArg.deltaX + sourceArg.deltaX);
99
+ targetArg.deltaY = clampWheelDelta(targetArg.deltaY + sourceArg.deltaY);
100
+ targetArg.modifiers = sourceArg.modifiers;
101
+ };
102
+
103
+ const inputIdentityEquals = (
104
+ firstArg: ILiveBrowserInputBase,
105
+ secondArg: ILiveBrowserInputBase,
106
+ ): boolean => (
107
+ firstArg.tabId === secondArg.tabId
108
+ && firstArg.generation === secondArg.generation
109
+ && firstArg.viewportRevision === secondArg.viewportRevision
110
+ );
111
+
59
112
  interface IResizeFence {
60
113
  minimumViewportRevision: number;
61
114
  target: ILiveBrowserViewport;
@@ -102,7 +155,8 @@ const getErrorMessage = (errorArg: unknown): string => (
102
155
  */
103
156
  export class LiveBrowserCanvasRenderer {
104
157
  private readonly canvas: HTMLCanvasElement;
105
- private readonly canvasContext: CanvasRenderingContext2D;
158
+ private readonly canvasContext?: CanvasRenderingContext2D;
159
+ private readonly bitmapContext?: ImageBitmapRenderingContext;
106
160
  private readonly client: ILiveBrowserCanvasRendererOptions['client'];
107
161
  private readonly focusTarget: HTMLElement;
108
162
  private readonly resizeTarget?: Element;
@@ -135,17 +189,22 @@ export class LiveBrowserCanvasRenderer {
135
189
  private highestFrameSequence = -1;
136
190
  private queuedFrame?: IFrameWork;
137
191
  private frameProcessingPromise?: Promise<void>;
192
+ private pendingDraw?: IPendingDraw;
193
+ private drawSchedule?: IScheduledFrame;
138
194
  private acknowledgementPromises = new Set<Promise<void>>();
139
195
  private rawDecodeSettlementPromises = new Set<Promise<void>>();
140
196
 
141
197
  private inputBlocked = true;
142
198
  private inputCommands: IInputCommand[] = [];
143
- private inputProcessing = false;
199
+ private inFlightInputCommands = 0;
200
+ private inFlightWheelCommands = 0;
144
201
  private inputIdleResolvers: Array<() => void> = [];
145
202
  private inputResetTail: Promise<void> = Promise.resolve();
146
203
  private inputResetPromise?: Promise<void>;
147
204
  private inputResetCount = 0;
148
- private inputOverflowRecoveryPending = false;
205
+ private pendingWheel?: IPendingWheel;
206
+ private wheelFlushSchedule?: IScheduledFrame;
207
+ private wheelFrameElapsed = true;
149
208
  private pressedKeys = new Map<string, ILiveBrowserKeyInput>();
150
209
  private pressedMouseButtons = new Map<string, ILiveBrowserMouseInput>();
151
210
  private capturedPointerIds = new Set<number>();
@@ -155,6 +214,17 @@ export class LiveBrowserCanvasRenderer {
155
214
  private viewportProcessing = false;
156
215
  private viewportProcessingPromise?: Promise<void>;
157
216
  private resizeFence?: IResizeFence;
217
+ private resizeDebounceTimer?: ReturnType<typeof setTimeout>;
218
+ private pendingResizeSize?: { width: number; height: number };
219
+
220
+ private readonly statistics: Omit<ILiveBrowserCanvasRendererStatistics, 'inputCommandsInFlight'> = {
221
+ framesReceived: 0,
222
+ framesDecoded: 0,
223
+ framesSkipped: 0,
224
+ inputCommandsEnqueued: 0,
225
+ inputCommandsCoalesced: 0,
226
+ lastInputRoundTripMs: 0,
227
+ };
158
228
 
159
229
  constructor(optionsArg: ILiveBrowserCanvasRendererOptions) {
160
230
  this.canvas = optionsArg.canvas;
@@ -176,11 +246,17 @@ export class LiveBrowserCanvasRenderer {
176
246
  this.initialCanvasWidth = this.canvas.width;
177
247
  this.initialCanvasHeight = this.canvas.height;
178
248
 
179
- const canvasContext = this.canvas.getContext('2d');
180
- if (!canvasContext) {
181
- throw new Error('LiveBrowserCanvasRenderer requires a 2D canvas context');
249
+ const bitmapContext = this.canvas.getContext('bitmaprenderer');
250
+ if (bitmapContext) {
251
+ this.bitmapContext = bitmapContext;
252
+ } else {
253
+ // A canvas that already owns a 2D context keeps the 2D drawing path.
254
+ const canvasContext = this.canvas.getContext('2d');
255
+ if (!canvasContext) {
256
+ throw new Error('LiveBrowserCanvasRenderer requires a bitmaprenderer or 2D canvas context');
257
+ }
258
+ this.canvasContext = canvasContext;
182
259
  }
183
- this.canvasContext = canvasContext;
184
260
  }
185
261
 
186
262
  public get isRunning(): boolean {
@@ -191,6 +267,16 @@ export class LiveBrowserCanvasRenderer {
191
267
  return this.suspended;
192
268
  }
193
269
 
270
+ /**
271
+ * Cumulative frame and input counters for this renderer instance across runs.
272
+ */
273
+ public getStatistics(): ILiveBrowserCanvasRendererStatistics {
274
+ return {
275
+ ...this.statistics,
276
+ inputCommandsInFlight: this.inFlightInputCommands,
277
+ };
278
+ }
279
+
194
280
  public start(): Promise<void> {
195
281
  return this.enqueueLifecycle(async () => {
196
282
  if (this.running) {
@@ -299,6 +385,7 @@ export class LiveBrowserCanvasRenderer {
299
385
  this.interruptionRequested = false;
300
386
  this.acceptingFrames = true;
301
387
  this.inputBlocked = true;
388
+ this.wheelFrameElapsed = true;
302
389
  this.state = undefined;
303
390
  this.displayedFrame = undefined;
304
391
  this.highestFrameSequence = -1;
@@ -335,6 +422,8 @@ export class LiveBrowserCanvasRenderer {
335
422
  this.queuedFrame = undefined;
336
423
  this.displayedFrame = undefined;
337
424
  this.resizeFence = undefined;
425
+ this.discardPendingDraw();
426
+ this.discardPendingWheel();
338
427
  this.releasePointerCaptures();
339
428
  this.clearCanvas();
340
429
 
@@ -352,7 +441,6 @@ export class LiveBrowserCanvasRenderer {
352
441
  }
353
442
  this.pressedKeys.clear();
354
443
  this.pressedMouseButtons.clear();
355
- this.inputOverflowRecoveryPending = false;
356
444
  }
357
445
 
358
446
  private async finishRun(runEpochArg: number, suspendArg: boolean): Promise<void> {
@@ -363,6 +451,7 @@ export class LiveBrowserCanvasRenderer {
363
451
  try {
364
452
  this.resizeObserver?.disconnect();
365
453
  this.resizeObserver = undefined;
454
+ this.clearResizeDebounce();
366
455
  this.listenerController?.abort();
367
456
  this.listenerController = undefined;
368
457
  const unsubscribe = this.unsubscribe;
@@ -402,6 +491,9 @@ export class LiveBrowserCanvasRenderer {
402
491
  this.highestFrameSequence = -1;
403
492
  this.queuedFrame = undefined;
404
493
  this.frameProcessingPromise = undefined;
494
+ this.discardPendingDraw();
495
+ this.discardPendingWheel();
496
+ this.clearResizeDebounce();
405
497
  this.acknowledgementPromises.clear();
406
498
  this.resizeFence = undefined;
407
499
  this.pendingViewport = undefined;
@@ -498,19 +590,37 @@ export class LiveBrowserCanvasRenderer {
498
590
  }
499
591
  this.resizeObserver = new ResizeObserver((entriesArg) => {
500
592
  const matchingEntry = entriesArg.find((entryArg) => entryArg.target === this.resizeTarget);
501
- if (!matchingEntry) {
593
+ if (!matchingEntry || runEpochArg !== this.runEpoch) {
502
594
  return;
503
595
  }
504
- this.queueViewport(
505
- matchingEntry.contentRect.width,
506
- matchingEntry.contentRect.height,
507
- runEpochArg,
508
- );
596
+ this.pendingResizeSize = {
597
+ width: matchingEntry.contentRect.width,
598
+ height: matchingEntry.contentRect.height,
599
+ };
600
+ if (this.resizeDebounceTimer !== undefined) {
601
+ globalThis.clearTimeout(this.resizeDebounceTimer);
602
+ }
603
+ this.resizeDebounceTimer = globalThis.setTimeout(() => {
604
+ this.resizeDebounceTimer = undefined;
605
+ const pendingResizeSize = this.pendingResizeSize;
606
+ this.pendingResizeSize = undefined;
607
+ if (pendingResizeSize) {
608
+ this.queueViewport(pendingResizeSize.width, pendingResizeSize.height, runEpochArg);
609
+ }
610
+ }, resizeDebounceMs);
509
611
  });
510
612
  this.resizeObserver.observe(this.resizeTarget);
511
613
  this.syncViewport();
512
614
  }
513
615
 
616
+ private clearResizeDebounce(): void {
617
+ if (this.resizeDebounceTimer !== undefined) {
618
+ globalThis.clearTimeout(this.resizeDebounceTimer);
619
+ this.resizeDebounceTimer = undefined;
620
+ }
621
+ this.pendingResizeSize = undefined;
622
+ }
623
+
514
624
  private handleEvent(eventArg: TLiveBrowserEvent, runEpochArg: number): void {
515
625
  if (eventArg.type === 'frame') {
516
626
  this.queueFrame(eventArg.frame, runEpochArg);
@@ -564,6 +674,7 @@ export class LiveBrowserCanvasRenderer {
564
674
  if (!this.running || !this.acceptingFrames || runEpochArg !== this.runEpoch) {
565
675
  return;
566
676
  }
677
+ this.statistics.framesReceived++;
567
678
 
568
679
  try {
569
680
  this.validateFrame(frameArg);
@@ -590,11 +701,12 @@ export class LiveBrowserCanvasRenderer {
590
701
  acknowledgementAttempted: false,
591
702
  };
592
703
  if (this.queuedFrame) {
593
- const supersededFrame = this.queuedFrame;
704
+ // The superseded frame was already acknowledged at receipt.
594
705
  this.queuedFrame = undefined;
595
- void this.acknowledgeFrame(supersededFrame);
706
+ this.statistics.framesSkipped++;
596
707
  }
597
708
  this.queuedFrame = frameWork;
709
+ void this.acknowledgeFrame(frameWork);
598
710
 
599
711
  this.ensureFrameProcessor();
600
712
  }
@@ -643,32 +755,14 @@ export class LiveBrowserCanvasRenderer {
643
755
  `decoded frame dimensions ${imageBitmap.width}x${imageBitmap.height} do not match ${frame.width}x${frame.height}`,
644
756
  );
645
757
  }
646
- if (!this.canRenderFrame(frame, runEpoch) || frame.sequence !== this.highestFrameSequence) {
758
+ this.statistics.framesDecoded++;
759
+ if (!this.canRenderFrame(frame, runEpoch)) {
760
+ this.statistics.framesSkipped++;
647
761
  return;
648
762
  }
649
763
 
650
- this.canvas.width = frame.width;
651
- this.canvas.height = frame.height;
652
- this.canvasContext.clearRect(0, 0, frame.width, frame.height);
653
- this.canvasContext.drawImage(imageBitmap, 0, 0, frame.width, frame.height);
654
- this.displayedFrame = {
655
- tabId: frame.tabId,
656
- sequence: frame.sequence,
657
- generation: frame.generation,
658
- viewportRevision: frame.viewportRevision,
659
- viewport: { ...frame.viewport },
660
- };
661
-
662
- if (
663
- this.resizeFence
664
- && !this.viewportProcessing
665
- && frame.viewportRevision >= this.resizeFence.minimumViewportRevision
666
- && viewportEquals(frame.viewport, this.resizeFence.target)
667
- ) {
668
- this.resizeFence = undefined;
669
- }
670
- this.updateInputAvailability();
671
- this.callFrameRendered(frame, runEpoch);
764
+ this.setPendingDraw({ bitmap: imageBitmap, frame, runEpoch });
765
+ imageBitmap = undefined;
672
766
  } catch (error) {
673
767
  if (error instanceof LiveBrowserRunAbortedError) {
674
768
  return;
@@ -677,7 +771,6 @@ export class LiveBrowserCanvasRenderer {
677
771
  terminalError = error;
678
772
  } finally {
679
773
  imageBitmap?.close();
680
- await this.acknowledgeFrame(frameWorkArg);
681
774
  if (terminalFailure) {
682
775
  this.requestSuspend(runEpoch);
683
776
  this.reportError({
@@ -690,6 +783,104 @@ export class LiveBrowserCanvasRenderer {
690
783
  }
691
784
  }
692
785
 
786
+ /**
787
+ * Keeps only the newest decoded bitmap and presents it on the next animation frame.
788
+ */
789
+ private setPendingDraw(pendingDrawArg: IPendingDraw): void {
790
+ if (this.pendingDraw) {
791
+ this.pendingDraw.bitmap.close();
792
+ this.statistics.framesSkipped++;
793
+ }
794
+ this.pendingDraw = pendingDrawArg;
795
+ if (!this.drawSchedule) {
796
+ this.drawSchedule = this.scheduleAnimationFrame(() => {
797
+ this.drawSchedule = undefined;
798
+ this.drawPendingFrame();
799
+ });
800
+ }
801
+ }
802
+
803
+ private discardPendingDraw(): void {
804
+ if (this.drawSchedule) {
805
+ this.cancelScheduledFrame(this.drawSchedule);
806
+ this.drawSchedule = undefined;
807
+ }
808
+ const pendingDraw = this.pendingDraw;
809
+ this.pendingDraw = undefined;
810
+ if (pendingDraw) {
811
+ pendingDraw.bitmap.close();
812
+ this.statistics.framesSkipped++;
813
+ }
814
+ }
815
+
816
+ private drawPendingFrame(): void {
817
+ const pendingDraw = this.pendingDraw;
818
+ this.pendingDraw = undefined;
819
+ if (!pendingDraw) {
820
+ return;
821
+ }
822
+ const { bitmap, frame, runEpoch } = pendingDraw;
823
+ if (!this.canRenderFrame(frame, runEpoch)) {
824
+ bitmap.close();
825
+ this.statistics.framesSkipped++;
826
+ return;
827
+ }
828
+ try {
829
+ this.paintBitmap(bitmap, frame.width, frame.height);
830
+ } catch (error) {
831
+ bitmap.close();
832
+ this.requestSuspend(runEpoch);
833
+ this.reportError({
834
+ code: 'frame_render_failed',
835
+ message: `Could not render live browser frame: ${getErrorMessage(error)}`,
836
+ cause: error,
837
+ frame,
838
+ }, runEpoch);
839
+ return;
840
+ }
841
+ this.displayedFrame = {
842
+ tabId: frame.tabId,
843
+ sequence: frame.sequence,
844
+ generation: frame.generation,
845
+ viewportRevision: frame.viewportRevision,
846
+ viewport: { ...frame.viewport },
847
+ };
848
+
849
+ if (
850
+ this.resizeFence
851
+ && !this.viewportProcessing
852
+ && frame.viewportRevision >= this.resizeFence.minimumViewportRevision
853
+ && viewportEquals(frame.viewport, this.resizeFence.target)
854
+ ) {
855
+ this.resizeFence = undefined;
856
+ }
857
+ this.updateInputAvailability();
858
+ this.callFrameRendered(frame, runEpoch);
859
+ }
860
+
861
+ /**
862
+ * Presents one decoded bitmap. The canvas backing store is reallocated only
863
+ * when the frame dimensions differ from the current intrinsic dimensions.
864
+ */
865
+ private paintBitmap(bitmapArg: ImageBitmap, widthArg: number, heightArg: number): void {
866
+ if (this.canvas.width !== widthArg) {
867
+ this.canvas.width = widthArg;
868
+ }
869
+ if (this.canvas.height !== heightArg) {
870
+ this.canvas.height = heightArg;
871
+ }
872
+ if (this.bitmapContext) {
873
+ this.bitmapContext.transferFromImageBitmap(bitmapArg);
874
+ return;
875
+ }
876
+ if (!this.canvasContext) {
877
+ throw new Error('LiveBrowserCanvasRenderer has no rendering context');
878
+ }
879
+ this.canvasContext.clearRect(0, 0, widthArg, heightArg);
880
+ this.canvasContext.drawImage(bitmapArg, 0, 0, widthArg, heightArg);
881
+ bitmapArg.close();
882
+ }
883
+
693
884
  private canRenderFrame(frameArg: ILiveBrowserFrame, runEpochArg: number): boolean {
694
885
  if (
695
886
  !this.running
@@ -889,20 +1080,110 @@ export class LiveBrowserCanvasRenderer {
889
1080
  : eventArg.deltaMode === WheelEvent.DOM_DELTA_PAGE
890
1081
  ? displayedFrame.viewport.height
891
1082
  : 1;
892
- const clampDelta = (valueArg: number) => Math.max(-1000000, Math.min(1000000, valueArg));
1083
+ const inputIdentity = createInputIdentity(displayedFrame);
1084
+ const modifiers = createModifiers(eventArg);
1085
+ this.statistics.inputCommandsEnqueued++;
1086
+ const pendingWheel = this.pendingWheel;
1087
+ if (
1088
+ pendingWheel
1089
+ && pendingWheel.runEpoch === runEpochArg
1090
+ && inputIdentityEquals(pendingWheel, inputIdentity)
1091
+ ) {
1092
+ mergeWheelInput(pendingWheel, {
1093
+ ...inputIdentity,
1094
+ ...coordinates,
1095
+ deltaX: eventArg.deltaX * multiplier,
1096
+ deltaY: eventArg.deltaY * multiplier,
1097
+ modifiers,
1098
+ });
1099
+ this.statistics.inputCommandsCoalesced++;
1100
+ } else {
1101
+ if (pendingWheel) {
1102
+ this.flushPendingWheel();
1103
+ }
1104
+ this.pendingWheel = {
1105
+ ...inputIdentity,
1106
+ ...coordinates,
1107
+ deltaX: clampWheelDelta(eventArg.deltaX * multiplier),
1108
+ deltaY: clampWheelDelta(eventArg.deltaY * multiplier),
1109
+ modifiers,
1110
+ runEpoch: runEpochArg,
1111
+ };
1112
+ }
1113
+ this.tryFlushPendingWheel();
1114
+ }
1115
+
1116
+ /**
1117
+ * Flushes accumulated wheel input at most once per animation frame and only
1118
+ * while no wheel dispatch is in flight; the settling dispatch flushes the rest.
1119
+ */
1120
+ private tryFlushPendingWheel(): void {
1121
+ if (!this.pendingWheel) {
1122
+ return;
1123
+ }
1124
+ if (!this.wheelFrameElapsed) {
1125
+ this.scheduleWheelFlush();
1126
+ return;
1127
+ }
1128
+ if (this.inFlightWheelCommands > 0) {
1129
+ return;
1130
+ }
1131
+ this.flushPendingWheel();
1132
+ }
1133
+
1134
+ private scheduleWheelFlush(): void {
1135
+ if (this.wheelFlushSchedule) {
1136
+ return;
1137
+ }
1138
+ this.wheelFlushSchedule = this.scheduleAnimationFrame(() => {
1139
+ this.wheelFlushSchedule = undefined;
1140
+ this.wheelFrameElapsed = true;
1141
+ this.tryFlushPendingWheel();
1142
+ });
1143
+ }
1144
+
1145
+ /**
1146
+ * Moves the accumulated wheel input into the command queue regardless of the
1147
+ * frame gate so that later discrete commands stay behind it.
1148
+ */
1149
+ private flushPendingWheel(): void {
1150
+ const pendingWheel = this.pendingWheel;
1151
+ if (!pendingWheel) {
1152
+ return;
1153
+ }
1154
+ this.pendingWheel = undefined;
1155
+ this.wheelFrameElapsed = false;
1156
+ this.scheduleWheelFlush();
1157
+ const displayedFrame = this.getInputFrame();
1158
+ if (
1159
+ !displayedFrame
1160
+ || pendingWheel.runEpoch !== this.runEpoch
1161
+ || !inputIdentityEquals(pendingWheel, createInputIdentity(displayedFrame))
1162
+ ) {
1163
+ return;
1164
+ }
1165
+ const { runEpoch, ...wheelInput } = pendingWheel;
893
1166
  void this.enqueueInputCommand({
894
1167
  kind: 'wheel',
895
- runEpoch: runEpochArg,
1168
+ runEpoch,
1169
+ wheelInput,
896
1170
  execute: async (optionsArg) => this.client.dispatchWheel({
897
- ...createInputIdentity(displayedFrame),
898
- ...coordinates,
899
- deltaX: clampDelta(eventArg.deltaX * multiplier),
900
- deltaY: clampDelta(eventArg.deltaY * multiplier),
901
- modifiers: createModifiers(eventArg),
1171
+ ...wheelInput,
1172
+ deltaX: clampWheelDelta(wheelInput.deltaX),
1173
+ deltaY: clampWheelDelta(wheelInput.deltaY),
902
1174
  }, optionsArg),
903
1175
  }).catch(() => undefined);
904
1176
  }
905
1177
 
1178
+ private discardPendingWheel(): void {
1179
+ this.pendingWheel = undefined;
1180
+ if (this.wheelFlushSchedule) {
1181
+ this.cancelScheduledFrame(this.wheelFlushSchedule);
1182
+ this.wheelFlushSchedule = undefined;
1183
+ }
1184
+ this.wheelFrameElapsed = true;
1185
+ }
1186
+
906
1187
  private handleKey(
907
1188
  eventArg: KeyboardEvent,
908
1189
  typeArg: 'down' | 'up',
@@ -1028,97 +1309,175 @@ export class LiveBrowserCanvasRenderer {
1028
1309
  resolve,
1029
1310
  reject,
1030
1311
  };
1031
- const lastCommand = this.inputCommands.at(-1);
1032
- if (command.kind === 'mouseMove' && lastCommand?.kind === 'mouseMove') {
1033
- lastCommand.resolve();
1034
- this.inputCommands[this.inputCommands.length - 1] = command;
1035
- } else if (this.inputCommands.length >= maxInputQueueLength) {
1036
- if (command.kind === 'mouseMove') {
1037
- resolve();
1312
+ if (isCoalescableInputCommand(command.kind)) {
1313
+ this.enqueueCoalescableCommand(command);
1314
+ } else {
1315
+ const queuedDiscreteCommands = this.inputCommands.filter((queuedCommand) => (
1316
+ !isCoalescableInputCommand(queuedCommand.kind)
1317
+ )).length;
1318
+ if (queuedDiscreteCommands >= maxInputQueueLength) {
1319
+ const error = new Error('Live browser input queue reached its capacity');
1320
+ this.reportError({
1321
+ code: 'input_queue_capacity_exceeded',
1322
+ message: error.message,
1323
+ cause: error,
1324
+ }, command.runEpoch);
1325
+ reject(error);
1038
1326
  return;
1039
1327
  }
1040
- const error = new Error('Live browser input queue reached its capacity');
1041
- this.reportError({
1042
- code: 'input_queue_capacity_exceeded',
1043
- message: error.message,
1044
- cause: error,
1045
- }, command.runEpoch);
1046
- reject(error);
1047
- this.recoverFromInputOverflow(command.runEpoch);
1048
- return;
1049
- } else {
1328
+ // Accumulated wheel input precedes this command in event order.
1329
+ this.flushPendingWheel();
1330
+ this.statistics.inputCommandsEnqueued++;
1050
1331
  this.inputCommands.push(command);
1051
1332
  }
1052
1333
  this.processInputCommands();
1053
1334
  });
1054
1335
  }
1055
1336
 
1056
- private processInputCommands(): void {
1057
- if (this.inputProcessing) {
1337
+ /**
1338
+ * Coalescable commands never exhaust capacity: they merge into a same-kind
1339
+ * queue tail or evict the oldest queued coalescable command under pressure.
1340
+ */
1341
+ private enqueueCoalescableCommand(commandArg: IInputCommand): void {
1342
+ // Wheel submissions are counted per DOM event before accumulation.
1343
+ if (commandArg.kind === 'mouseMove') {
1344
+ this.statistics.inputCommandsEnqueued++;
1345
+ }
1346
+ const lastCommand = this.inputCommands.at(-1);
1347
+ if (lastCommand && lastCommand.kind === commandArg.kind) {
1348
+ this.statistics.inputCommandsCoalesced++;
1349
+ if (commandArg.kind === 'wheel' && lastCommand.wheelInput && commandArg.wheelInput) {
1350
+ mergeWheelInput(lastCommand.wheelInput, commandArg.wheelInput);
1351
+ commandArg.resolve();
1352
+ } else {
1353
+ lastCommand.resolve();
1354
+ this.inputCommands[this.inputCommands.length - 1] = commandArg;
1355
+ }
1058
1356
  return;
1059
1357
  }
1060
- this.inputProcessing = true;
1061
- void (async () => {
1062
- try {
1063
- while (this.inputCommands.length > 0) {
1064
- const command = this.inputCommands.shift()!;
1065
- if (!this.isRunActive(command.runEpoch)) {
1066
- command.reject(new LiveBrowserRunAbortedError(
1067
- `Live browser input belongs to inactive run ${command.runEpoch}`,
1068
- ));
1069
- continue;
1070
- }
1071
- try {
1072
- command.onAttempt?.();
1073
- await this.runClientOperation(command.execute, 'input dispatch', command.runEpoch);
1074
- if (!this.isRunActive(command.runEpoch)) {
1075
- throw new LiveBrowserRunAbortedError(
1076
- `Live browser input belongs to inactive run ${command.runEpoch}`,
1077
- );
1078
- }
1079
- command.onSuccess?.();
1080
- command.resolve();
1081
- } catch (error) {
1082
- command.reject(error);
1083
- if (error instanceof LiveBrowserRunAbortedError) {
1084
- continue;
1085
- }
1086
- if (error instanceof LiveBrowserOperationTimeoutError) {
1087
- this.requestSuspend(command.runEpoch);
1088
- }
1089
- this.reportError({
1090
- code: 'input_dispatch_failed',
1091
- message: `Could not dispatch live browser input: ${getErrorMessage(error)}`,
1092
- cause: error,
1093
- }, command.runEpoch);
1094
- const abandonedCommands = this.inputCommands.splice(0);
1095
- for (const abandonedCommand of abandonedCommands) {
1096
- abandonedCommand.reject(
1097
- new Error('Live browser input was abandoned after a dispatch failure'),
1098
- );
1099
- }
1100
- if (!(error instanceof LiveBrowserOperationTimeoutError) && !this.stopping) {
1101
- void this.scheduleInputReset(true, command.runEpoch);
1102
- }
1103
- }
1104
- }
1105
- } finally {
1106
- this.inputProcessing = false;
1107
- if (this.inputCommands.length > 0) {
1108
- this.processInputCommands();
1109
- } else {
1110
- const resolvers = this.inputIdleResolvers;
1111
- this.inputIdleResolvers = [];
1112
- for (const resolver of resolvers) {
1113
- resolver();
1114
- }
1358
+ const queuedCoalescableCommands = this.inputCommands.filter((queuedCommand) => (
1359
+ isCoalescableInputCommand(queuedCommand.kind)
1360
+ ));
1361
+ if (queuedCoalescableCommands.length >= maxQueuedCoalescableCommands) {
1362
+ this.dropOldestCoalescableCommand(queuedCoalescableCommands[0]);
1363
+ }
1364
+ this.inputCommands.push(commandArg);
1365
+ }
1366
+
1367
+ private dropOldestCoalescableCommand(oldestCommandArg: IInputCommand): void {
1368
+ const index = this.inputCommands.indexOf(oldestCommandArg);
1369
+ if (index < 0) {
1370
+ return;
1371
+ }
1372
+ this.inputCommands.splice(index, 1);
1373
+ this.statistics.inputCommandsCoalesced++;
1374
+ if (oldestCommandArg.kind === 'wheel' && oldestCommandArg.wheelInput) {
1375
+ const nextWheelCommand = this.inputCommands.find((queuedCommand) => (
1376
+ queuedCommand.kind === 'wheel' && queuedCommand.wheelInput
1377
+ ));
1378
+ if (nextWheelCommand?.wheelInput) {
1379
+ const nextWheelInput = nextWheelCommand.wheelInput;
1380
+ nextWheelInput.deltaX = clampWheelDelta(
1381
+ nextWheelInput.deltaX + oldestCommandArg.wheelInput.deltaX,
1382
+ );
1383
+ nextWheelInput.deltaY = clampWheelDelta(
1384
+ nextWheelInput.deltaY + oldestCommandArg.wheelInput.deltaY,
1385
+ );
1386
+ }
1387
+ }
1388
+ oldestCommandArg.resolve();
1389
+ }
1390
+
1391
+ /**
1392
+ * Dispatches queued commands in order with a bounded number in flight. The
1393
+ * remote session processes operations sequentially, so order is preserved.
1394
+ */
1395
+ private processInputCommands(): void {
1396
+ while (
1397
+ this.inputCommands.length > 0
1398
+ && this.inFlightInputCommands < maxInFlightInputCommands
1399
+ ) {
1400
+ const command = this.inputCommands.shift()!;
1401
+ if (!this.isRunActive(command.runEpoch)) {
1402
+ command.reject(new LiveBrowserRunAbortedError(
1403
+ `Live browser input belongs to inactive run ${command.runEpoch}`,
1404
+ ));
1405
+ continue;
1406
+ }
1407
+ this.dispatchInputCommand(command);
1408
+ }
1409
+ if (this.inputCommands.length === 0 && this.inFlightInputCommands === 0) {
1410
+ const resolvers = this.inputIdleResolvers;
1411
+ this.inputIdleResolvers = [];
1412
+ for (const resolver of resolvers) {
1413
+ resolver();
1414
+ }
1415
+ }
1416
+ }
1417
+
1418
+ private dispatchInputCommand(commandArg: IInputCommand): void {
1419
+ this.inFlightInputCommands++;
1420
+ if (commandArg.kind === 'wheel') {
1421
+ this.inFlightWheelCommands++;
1422
+ }
1423
+ const startedAt = performance.now();
1424
+ let dispatch: Promise<void>;
1425
+ try {
1426
+ commandArg.onAttempt?.();
1427
+ dispatch = this.runClientOperation(commandArg.execute, 'input dispatch', commandArg.runEpoch);
1428
+ } catch (error) {
1429
+ dispatch = Promise.reject(error);
1430
+ }
1431
+ void dispatch.then(() => {
1432
+ if (!this.isRunActive(commandArg.runEpoch)) {
1433
+ throw new LiveBrowserRunAbortedError(
1434
+ `Live browser input belongs to inactive run ${commandArg.runEpoch}`,
1435
+ );
1436
+ }
1437
+ this.statistics.lastInputRoundTripMs = performance.now() - startedAt;
1438
+ commandArg.onSuccess?.();
1439
+ commandArg.resolve();
1440
+ }).catch((error) => {
1441
+ commandArg.reject(error);
1442
+ this.handleInputDispatchFailure(error, commandArg.runEpoch);
1443
+ }).finally(() => {
1444
+ this.inFlightInputCommands--;
1445
+ if (commandArg.kind === 'wheel') {
1446
+ this.inFlightWheelCommands--;
1447
+ if (this.inFlightWheelCommands === 0) {
1448
+ this.tryFlushPendingWheel();
1115
1449
  }
1116
1450
  }
1117
- })();
1451
+ this.processInputCommands();
1452
+ });
1453
+ }
1454
+
1455
+ private handleInputDispatchFailure(errorArg: unknown, runEpochArg: number): void {
1456
+ if (errorArg instanceof LiveBrowserRunAbortedError) {
1457
+ return;
1458
+ }
1459
+ if (errorArg instanceof LiveBrowserOperationTimeoutError) {
1460
+ this.requestSuspend(runEpochArg);
1461
+ }
1462
+ this.reportError({
1463
+ code: 'input_dispatch_failed',
1464
+ message: `Could not dispatch live browser input: ${getErrorMessage(errorArg)}`,
1465
+ cause: errorArg,
1466
+ }, runEpochArg);
1467
+ this.discardPendingWheel();
1468
+ const abandonedCommands = this.inputCommands.splice(0);
1469
+ for (const abandonedCommand of abandonedCommands) {
1470
+ abandonedCommand.reject(
1471
+ new Error('Live browser input was abandoned after a dispatch failure'),
1472
+ );
1473
+ }
1474
+ if (!(errorArg instanceof LiveBrowserOperationTimeoutError) && !this.stopping) {
1475
+ void this.scheduleInputReset(true, runEpochArg);
1476
+ }
1118
1477
  }
1119
1478
 
1120
1479
  private waitForInputIdle(): Promise<void> {
1121
- if (!this.inputProcessing && this.inputCommands.length === 0) {
1480
+ if (this.inFlightInputCommands === 0 && this.inputCommands.length === 0) {
1122
1481
  return Promise.resolve();
1123
1482
  }
1124
1483
  return new Promise<void>((resolve) => {
@@ -1159,17 +1518,6 @@ export class LiveBrowserCanvasRenderer {
1159
1518
  return resetOperation;
1160
1519
  }
1161
1520
 
1162
- private recoverFromInputOverflow(runEpochArg: number): void {
1163
- if (this.inputOverflowRecoveryPending) {
1164
- return;
1165
- }
1166
- this.inputOverflowRecoveryPending = true;
1167
- this.inputBlocked = true;
1168
- void this.scheduleInputReset(false, runEpochArg).finally(() => {
1169
- this.inputOverflowRecoveryPending = false;
1170
- });
1171
- }
1172
-
1173
1521
  private async releasePressedInput(runEpochArg: number): Promise<void> {
1174
1522
  for (const [keyIdentity, pressedKey] of [...this.pressedKeys.entries()]) {
1175
1523
  const released = await this.tryInputRelease(
@@ -1447,7 +1795,46 @@ export class LiveBrowserCanvasRenderer {
1447
1795
  }
1448
1796
 
1449
1797
  private clearCanvas(): void {
1450
- this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
1798
+ if (this.bitmapContext) {
1799
+ this.bitmapContext.transferFromImageBitmap(null);
1800
+ return;
1801
+ }
1802
+ this.canvasContext?.clearRect(0, 0, this.canvas.width, this.canvas.height);
1803
+ }
1804
+
1805
+ /**
1806
+ * Runs work on the next animation frame, or after one nominal frame period
1807
+ * when the document is not visible and animation frames would not fire.
1808
+ */
1809
+ private scheduleAnimationFrame(callbackArg: () => void): IScheduledFrame {
1810
+ const schedule: IScheduledFrame = {};
1811
+ if (
1812
+ typeof document !== 'undefined'
1813
+ && document.visibilityState === 'visible'
1814
+ && typeof globalThis.requestAnimationFrame === 'function'
1815
+ ) {
1816
+ schedule.animationFrameId = globalThis.requestAnimationFrame(() => {
1817
+ schedule.animationFrameId = undefined;
1818
+ callbackArg();
1819
+ });
1820
+ } else {
1821
+ schedule.timeoutId = globalThis.setTimeout(() => {
1822
+ schedule.timeoutId = undefined;
1823
+ callbackArg();
1824
+ }, animationFrameFallbackMs);
1825
+ }
1826
+ return schedule;
1827
+ }
1828
+
1829
+ private cancelScheduledFrame(scheduleArg: IScheduledFrame): void {
1830
+ if (scheduleArg.animationFrameId !== undefined) {
1831
+ globalThis.cancelAnimationFrame(scheduleArg.animationFrameId);
1832
+ scheduleArg.animationFrameId = undefined;
1833
+ }
1834
+ if (scheduleArg.timeoutId !== undefined) {
1835
+ globalThis.clearTimeout(scheduleArg.timeoutId);
1836
+ scheduleArg.timeoutId = undefined;
1837
+ }
1451
1838
  }
1452
1839
 
1453
1840
  private callFrameRendered(frameArg: ILiveBrowserFrame, runEpochArg: number): void {