@push.rocks/smartbrowser 3.0.0 → 4.0.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.
@@ -11,17 +11,26 @@ import type {
11
11
 
12
12
  import type {
13
13
  ILiveBrowserCanvasError,
14
+ ILiveBrowserCanvasOperationOptions,
14
15
  ILiveBrowserCanvasRendererOptions,
15
- TLiveBrowserCanvasErrorCode,
16
16
  } from './interfaces.livebrowsercanvas.js';
17
17
 
18
18
  const maxInputQueueLength = 128;
19
19
  const maxPendingAcknowledgements = 16;
20
20
  const maxInputReleaseAttempts = 2;
21
+ const maxFrameDimension = 12288;
22
+ const maxFramePixelArea = 8294400;
23
+ const maxFrameByteLength = (maxFramePixelArea * 4) + 1048576;
24
+ const maxViewportWidth = 4096;
25
+ const maxViewportHeight = 4096;
26
+ const maxDeviceScaleFactor = 3;
21
27
  const coordinateEdgeInset = 0.001;
22
28
  const defaultOperationTimeoutMs = 10000;
23
29
 
24
30
  class LiveBrowserOperationTimeoutError extends Error {}
31
+ class LiveBrowserRunAbortedError extends Error {}
32
+ class LiveBrowserFrameProtocolError extends Error {}
33
+ class LiveBrowserFrameIntegrityError extends Error {}
25
34
 
26
35
  interface IFrameWork {
27
36
  frame: ILiveBrowserFrame;
@@ -39,7 +48,7 @@ type TInputCommandKind = 'key' | 'mouse' | 'mouseMove' | 'text' | 'wheel';
39
48
  interface IInputCommand {
40
49
  kind: TInputCommandKind;
41
50
  runEpoch: number;
42
- execute: () => Promise<void>;
51
+ execute: (optionsArg: ILiveBrowserCanvasOperationOptions) => Promise<void>;
43
52
  onAttempt?: () => void;
44
53
  onSuccess?: () => void;
45
54
  resolve: () => void;
@@ -107,9 +116,11 @@ export class LiveBrowserCanvasRenderer {
107
116
  private lifecycleTail: Promise<void> = Promise.resolve();
108
117
  private runEpoch = 0;
109
118
  private running = false;
119
+ private suspended = false;
110
120
  private stopping = false;
111
- private stopRequested = false;
112
- private terminallyStopped = false;
121
+ private interruptionRequested = false;
122
+ private runController?: AbortController;
123
+ private operationControllers = new Set<AbortController>();
113
124
  private acceptingFrames = false;
114
125
  private state?: ILiveBrowserState;
115
126
  private unsubscribe?: () => void;
@@ -124,6 +135,7 @@ export class LiveBrowserCanvasRenderer {
124
135
  private queuedFrame?: IFrameWork;
125
136
  private frameProcessingPromise?: Promise<void>;
126
137
  private acknowledgementPromises = new Set<Promise<void>>();
138
+ private rawDecodeSettlementPromises = new Set<Promise<void>>();
127
139
 
128
140
  private inputBlocked = true;
129
141
  private inputCommands: IInputCommand[] = [];
@@ -174,46 +186,66 @@ export class LiveBrowserCanvasRenderer {
174
186
  return this.running;
175
187
  }
176
188
 
189
+ public get isSuspended(): boolean {
190
+ return this.suspended;
191
+ }
192
+
177
193
  public start(): Promise<void> {
178
194
  return this.enqueueLifecycle(async () => {
179
- if (this.terminallyStopped) {
180
- throw new Error(
181
- 'Live browser renderer cannot restart after a terminal operation failure; create a new renderer and client',
182
- );
183
- }
184
195
  if (this.running) {
185
196
  return;
186
197
  }
198
+ if (this.suspended) {
199
+ throw new Error('Live browser renderer is suspended; call resume() to start a fresh run');
200
+ }
201
+ await this.startRun(false);
202
+ });
203
+ }
187
204
 
188
- const runEpoch = ++this.runEpoch;
189
- this.running = true;
190
- this.stopping = false;
191
- this.stopRequested = false;
192
- this.acceptingFrames = true;
193
- this.inputBlocked = true;
194
- this.highestFrameSequence = -1;
205
+ public stop(): Promise<void> {
206
+ this.interruptRun(this.runEpoch);
207
+ return this.enqueueLifecycle(async () => {
208
+ const runEpoch = this.runEpoch;
209
+ this.interruptRun(runEpoch);
210
+ if (this.stopping) {
211
+ await this.finishRun(runEpoch, false);
212
+ return;
213
+ }
214
+ if (!this.suspended) {
215
+ return;
216
+ }
217
+ this.suspended = false;
218
+ this.restoreCanvasAndFocus();
219
+ });
220
+ }
195
221
 
196
- try {
197
- this.makeFocusTargetFocusable();
198
- this.installDomListeners(runEpoch);
199
- this.unsubscribe = this.client.onEvent((eventArg) => {
200
- this.handleEvent(eventArg, runEpoch);
201
- });
202
- this.applyState(this.client.getState(), runEpoch);
203
- this.installResizeObserver(runEpoch);
204
- } catch (error) {
205
- await this.stopRun(runEpoch);
206
- throw error;
222
+ /**
223
+ * Aborts the current transport generation without replaying input recovery.
224
+ */
225
+ public suspend(): Promise<void> {
226
+ this.interruptRun(this.runEpoch);
227
+ return this.enqueueLifecycle(async () => {
228
+ const runEpoch = this.runEpoch;
229
+ this.interruptRun(runEpoch);
230
+ if (!this.stopping) {
231
+ return;
207
232
  }
233
+ await this.finishRun(runEpoch, true);
208
234
  });
209
235
  }
210
236
 
211
- public stop(): Promise<void> {
237
+ /**
238
+ * Starts a new transport generation that requires new state and a new frame.
239
+ */
240
+ public resume(): Promise<void> {
212
241
  return this.enqueueLifecycle(async () => {
213
- if (!this.running) {
242
+ if (this.running) {
214
243
  return;
215
244
  }
216
- await this.stopRun(this.runEpoch);
245
+ if (!this.suspended) {
246
+ throw new Error('Live browser renderer is not suspended');
247
+ }
248
+ await this.startRun(true);
217
249
  });
218
250
  }
219
251
 
@@ -226,11 +258,11 @@ export class LiveBrowserCanvasRenderer {
226
258
  await this.enqueueInputCommand({
227
259
  kind: 'text',
228
260
  runEpoch: this.runEpoch,
229
- execute: async () => {
261
+ execute: async (optionsArg) => {
230
262
  await this.client.insertText({
231
263
  ...inputIdentity,
232
264
  text: textArg,
233
- });
265
+ }, optionsArg);
234
266
  },
235
267
  });
236
268
  }
@@ -240,7 +272,7 @@ export class LiveBrowserCanvasRenderer {
240
272
  * remote CSS viewport. Intrinsic canvas dimensions are never used here.
241
273
  */
242
274
  public syncViewport(): void {
243
- if (!this.running || this.stopping || this.stopRequested || !this.resizeTarget) {
275
+ if (!this.running || this.stopping || this.interruptionRequested || !this.resizeTarget) {
244
276
  return;
245
277
  }
246
278
  const width = Math.floor(this.resizeTarget.clientWidth);
@@ -254,12 +286,76 @@ export class LiveBrowserCanvasRenderer {
254
286
  return operation;
255
287
  }
256
288
 
257
- private async stopRun(runEpochArg: number): Promise<void> {
289
+ private async startRun(resumingArg: boolean): Promise<void> {
290
+ await Promise.all([...this.rawDecodeSettlementPromises]);
291
+ const runEpoch = ++this.runEpoch;
292
+ this.runController = new AbortController();
293
+ this.running = true;
294
+ this.suspended = false;
295
+ this.stopping = false;
296
+ this.interruptionRequested = false;
297
+ this.acceptingFrames = true;
298
+ this.inputBlocked = true;
299
+ this.state = undefined;
300
+ this.displayedFrame = undefined;
301
+ this.highestFrameSequence = -1;
302
+ this.clearCanvas();
303
+
304
+ try {
305
+ this.makeFocusTargetFocusable();
306
+ this.installDomListeners(runEpoch);
307
+ this.unsubscribe = this.client.onEvent((eventArg) => {
308
+ this.handleEvent(eventArg, runEpoch);
309
+ });
310
+ this.applyState(this.client.getState(), runEpoch);
311
+ this.installResizeObserver(runEpoch);
312
+ } catch (error) {
313
+ this.interruptRun(runEpoch);
314
+ await this.finishRun(runEpoch, resumingArg);
315
+ throw error;
316
+ }
317
+ }
318
+
319
+ private interruptRun(runEpochArg: number): void {
320
+ if (
321
+ runEpochArg !== this.runEpoch
322
+ || (!this.running && !this.stopping)
323
+ ) {
324
+ return;
325
+ }
326
+ this.running = false;
258
327
  this.stopping = true;
328
+ this.interruptionRequested = true;
259
329
  this.acceptingFrames = false;
260
330
  this.inputBlocked = true;
261
331
  this.pendingViewport = undefined;
332
+ this.queuedFrame = undefined;
333
+ this.displayedFrame = undefined;
334
+ this.resizeFence = undefined;
262
335
  this.releasePointerCaptures();
336
+ this.clearCanvas();
337
+
338
+ const interruptionError = new LiveBrowserRunAbortedError(
339
+ `Live browser renderer run ${runEpochArg} was interrupted`,
340
+ );
341
+ this.runController?.abort(interruptionError);
342
+ for (const controller of this.operationControllers) {
343
+ controller.abort(interruptionError);
344
+ }
345
+
346
+ const abandonedCommands = this.inputCommands.splice(0);
347
+ for (const command of abandonedCommands) {
348
+ command.reject(interruptionError);
349
+ }
350
+ this.pressedKeys.clear();
351
+ this.pressedMouseButtons.clear();
352
+ this.inputOverflowRecoveryPending = false;
353
+ }
354
+
355
+ private async finishRun(runEpochArg: number, suspendArg: boolean): Promise<void> {
356
+ if (runEpochArg !== this.runEpoch) {
357
+ return;
358
+ }
263
359
 
264
360
  try {
265
361
  this.resizeObserver?.disconnect();
@@ -280,30 +376,38 @@ export class LiveBrowserCanvasRenderer {
280
376
  }
281
377
  }
282
378
 
283
- if (this.queuedFrame) {
284
- const queuedFrame = this.queuedFrame;
285
- this.queuedFrame = undefined;
286
- await this.acknowledgeFrame(queuedFrame);
287
- }
288
-
289
- await this.viewportProcessingPromise;
290
- await this.inputResetTail;
291
- await this.waitForInputIdle();
292
- await this.releasePressedInput(runEpochArg);
293
-
294
- this.running = false;
295
- await this.frameProcessingPromise;
296
- await Promise.all([...this.acknowledgementPromises]);
379
+ await Promise.allSettled([
380
+ this.viewportProcessingPromise,
381
+ this.inputResetTail,
382
+ this.waitForInputIdle(),
383
+ this.frameProcessingPromise,
384
+ ...this.acknowledgementPromises,
385
+ ]);
297
386
  } finally {
387
+ for (const controller of this.operationControllers) {
388
+ controller.abort(new LiveBrowserRunAbortedError(
389
+ `Live browser renderer run ${runEpochArg} finished`,
390
+ ));
391
+ }
392
+ this.operationControllers.clear();
393
+ this.runController = undefined;
298
394
  this.running = false;
395
+ this.suspended = suspendArg;
299
396
  this.acceptingFrames = false;
300
397
  this.state = undefined;
301
398
  this.displayedFrame = undefined;
399
+ this.highestFrameSequence = -1;
400
+ this.queuedFrame = undefined;
401
+ this.frameProcessingPromise = undefined;
402
+ this.acknowledgementPromises.clear();
302
403
  this.resizeFence = undefined;
303
404
  this.pendingViewport = undefined;
304
405
  this.inFlightViewport = undefined;
305
406
  this.viewportProcessing = false;
407
+ this.viewportProcessingPromise = undefined;
306
408
  this.inputResetPromise = undefined;
409
+ this.inputResetTail = Promise.resolve();
410
+ this.inputResetCount = 0;
307
411
  this.inputBlocked = true;
308
412
  this.pressedKeys.clear();
309
413
  this.pressedMouseButtons.clear();
@@ -317,14 +421,18 @@ export class LiveBrowserCanvasRenderer {
317
421
  for (const resolver of inputIdleResolvers) {
318
422
  resolver();
319
423
  }
320
- this.canvas.width = this.initialCanvasWidth;
321
- this.canvas.height = this.initialCanvasHeight;
322
- this.restoreFocusTarget();
323
- this.stopRequested = false;
424
+ this.restoreCanvasAndFocus();
425
+ this.interruptionRequested = false;
324
426
  this.stopping = false;
325
427
  }
326
428
  }
327
429
 
430
+ private restoreCanvasAndFocus(): void {
431
+ this.canvas.width = this.initialCanvasWidth;
432
+ this.canvas.height = this.initialCanvasHeight;
433
+ this.restoreFocusTarget();
434
+ }
435
+
328
436
  private installDomListeners(runEpochArg: number): void {
329
437
  const listenerController = new AbortController();
330
438
  const signal = listenerController.signal;
@@ -450,18 +558,34 @@ export class LiveBrowserCanvasRenderer {
450
558
  }
451
559
 
452
560
  private queueFrame(frameArg: ILiveBrowserFrame, runEpochArg: number): void {
561
+ if (!this.running || !this.acceptingFrames || runEpochArg !== this.runEpoch) {
562
+ return;
563
+ }
564
+
565
+ try {
566
+ this.validateFrame(frameArg);
567
+ if (frameArg.sequence <= this.highestFrameSequence) {
568
+ throw new LiveBrowserFrameProtocolError(
569
+ `frame.sequence ${frameArg.sequence} must be strictly greater than current run high-water ${this.highestFrameSequence}`,
570
+ );
571
+ }
572
+ } catch (error) {
573
+ this.requestSuspend(runEpochArg);
574
+ this.reportError({
575
+ code: 'frame_render_failed',
576
+ message: `Rejected invalid live browser frame: ${getErrorMessage(error)}`,
577
+ cause: error,
578
+ frame: frameArg,
579
+ }, runEpochArg);
580
+ return;
581
+ }
582
+
583
+ this.highestFrameSequence = frameArg.sequence;
453
584
  const frameWork: IFrameWork = {
454
585
  frame: frameArg,
455
586
  runEpoch: runEpochArg,
456
587
  acknowledgementAttempted: false,
457
588
  };
458
-
459
- if (!this.running || !this.acceptingFrames || runEpochArg !== this.runEpoch) {
460
- void this.acknowledgeFrame(frameWork);
461
- return;
462
- }
463
-
464
- this.highestFrameSequence = Math.max(this.highestFrameSequence, frameArg.sequence);
465
589
  if (this.queuedFrame) {
466
590
  const supersededFrame = this.queuedFrame;
467
591
  this.queuedFrame = undefined;
@@ -498,6 +622,8 @@ export class LiveBrowserCanvasRenderer {
498
622
  private async processFrame(frameWorkArg: IFrameWork): Promise<void> {
499
623
  const { frame, runEpoch } = frameWorkArg;
500
624
  let imageBitmap: ImageBitmap | undefined;
625
+ let terminalFailure = false;
626
+ let terminalError: unknown;
501
627
  try {
502
628
  if (!this.canRenderFrame(frame, runEpoch)) {
503
629
  return;
@@ -507,16 +633,16 @@ export class LiveBrowserCanvasRenderer {
507
633
  frameBytes.set(frame.data);
508
634
  imageBitmap = await this.decodeFrame(new Blob([frameBytes.buffer], {
509
635
  type: frame.mimeType,
510
- }));
636
+ }), runEpoch);
511
637
 
512
- if (!this.canRenderFrame(frame, runEpoch) || frame.sequence !== this.highestFrameSequence) {
513
- return;
514
- }
515
638
  if (imageBitmap.width !== frame.width || imageBitmap.height !== frame.height) {
516
- throw new Error(
639
+ throw new LiveBrowserFrameIntegrityError(
517
640
  `decoded frame dimensions ${imageBitmap.width}x${imageBitmap.height} do not match ${frame.width}x${frame.height}`,
518
641
  );
519
642
  }
643
+ if (!this.canRenderFrame(frame, runEpoch) || frame.sequence !== this.highestFrameSequence) {
644
+ return;
645
+ }
520
646
 
521
647
  this.canvas.width = frame.width;
522
648
  this.canvas.height = frame.height;
@@ -541,18 +667,23 @@ export class LiveBrowserCanvasRenderer {
541
667
  this.updateInputAvailability();
542
668
  this.callFrameRendered(frame, runEpoch);
543
669
  } catch (error) {
544
- this.reportError({
545
- code: 'frame_render_failed',
546
- message: `Could not render live browser frame: ${getErrorMessage(error)}`,
547
- cause: error,
548
- frame,
549
- }, runEpoch);
550
- if (error instanceof LiveBrowserOperationTimeoutError) {
551
- this.requestStop(runEpoch);
670
+ if (error instanceof LiveBrowserRunAbortedError) {
671
+ return;
552
672
  }
673
+ terminalFailure = true;
674
+ terminalError = error;
553
675
  } finally {
554
676
  imageBitmap?.close();
555
677
  await this.acknowledgeFrame(frameWorkArg);
678
+ if (terminalFailure) {
679
+ this.requestSuspend(runEpoch);
680
+ this.reportError({
681
+ code: 'frame_render_failed',
682
+ message: `Could not render live browser frame: ${getErrorMessage(terminalError)}`,
683
+ cause: terminalError,
684
+ frame,
685
+ }, runEpoch);
686
+ }
556
687
  }
557
688
  }
558
689
 
@@ -592,28 +723,39 @@ export class LiveBrowserCanvasRenderer {
592
723
  }
593
724
  frameWorkArg.acknowledgementAttempted = true;
594
725
 
726
+ if (!this.isRunActive(frameWorkArg.runEpoch)) {
727
+ return Promise.resolve();
728
+ }
729
+
595
730
  if (this.acknowledgementPromises.size >= maxPendingAcknowledgements) {
731
+ this.requestSuspend(frameWorkArg.runEpoch);
596
732
  this.reportError({
597
733
  code: 'frame_acknowledgement_failed',
598
734
  message: `Live browser frame acknowledgement capacity of ${maxPendingAcknowledgements} was reached`,
599
735
  }, frameWorkArg.runEpoch);
600
- this.requestStop(frameWorkArg.runEpoch);
601
736
  return Promise.resolve();
602
737
  }
603
738
 
604
739
  const acknowledgement = this.runClientOperation(
605
- () => this.client.acknowledgeFrame(createFrameAcknowledgementRequest(frameWorkArg.frame)),
740
+ (optionsArg) => this.client.acknowledgeFrame(
741
+ createFrameAcknowledgementRequest(frameWorkArg.frame),
742
+ optionsArg,
743
+ ),
606
744
  'frame acknowledgement',
745
+ frameWorkArg.runEpoch,
607
746
  )
608
747
  .then(() => undefined)
609
748
  .catch((error) => {
749
+ if (error instanceof LiveBrowserRunAbortedError) {
750
+ return;
751
+ }
752
+ this.requestSuspend(frameWorkArg.runEpoch);
610
753
  this.reportError({
611
754
  code: 'frame_acknowledgement_failed',
612
755
  message: `Could not acknowledge live browser frame: ${getErrorMessage(error)}`,
613
756
  cause: error,
614
757
  frame: frameWorkArg.frame,
615
758
  }, frameWorkArg.runEpoch);
616
- this.requestStop(frameWorkArg.runEpoch);
617
759
  });
618
760
  let trackedAcknowledgement: Promise<void>;
619
761
  trackedAcknowledgement = acknowledgement.finally(() => {
@@ -659,7 +801,7 @@ export class LiveBrowserCanvasRenderer {
659
801
  void this.enqueueInputCommand({
660
802
  kind: 'mouse',
661
803
  runEpoch: runEpochArg,
662
- execute: async () => this.client.dispatchMouse(input),
804
+ execute: async (optionsArg) => this.client.dispatchMouse(input, optionsArg),
663
805
  onAttempt: () => this.pressedMouseButtons.set(button, input),
664
806
  }).catch(() => undefined);
665
807
  }
@@ -690,7 +832,7 @@ export class LiveBrowserCanvasRenderer {
690
832
  void this.enqueueInputCommand({
691
833
  kind: 'mouseMove',
692
834
  runEpoch: runEpochArg,
693
- execute: async () => this.client.dispatchMouse(input),
835
+ execute: async (optionsArg) => this.client.dispatchMouse(input, optionsArg),
694
836
  }).catch(() => undefined);
695
837
  }
696
838
 
@@ -723,7 +865,7 @@ export class LiveBrowserCanvasRenderer {
723
865
  void this.enqueueInputCommand({
724
866
  kind: 'mouse',
725
867
  runEpoch: runEpochArg,
726
- execute: async () => this.client.dispatchMouse(input),
868
+ execute: async (optionsArg) => this.client.dispatchMouse(input, optionsArg),
727
869
  onSuccess: () => this.pressedMouseButtons.delete(button),
728
870
  }).catch(() => undefined);
729
871
  this.releasePointerCapture(eventArg.pointerId);
@@ -748,13 +890,13 @@ export class LiveBrowserCanvasRenderer {
748
890
  void this.enqueueInputCommand({
749
891
  kind: 'wheel',
750
892
  runEpoch: runEpochArg,
751
- execute: async () => this.client.dispatchWheel({
893
+ execute: async (optionsArg) => this.client.dispatchWheel({
752
894
  ...createInputIdentity(displayedFrame),
753
895
  ...coordinates,
754
896
  deltaX: clampDelta(eventArg.deltaX * multiplier),
755
897
  deltaY: clampDelta(eventArg.deltaY * multiplier),
756
898
  modifiers: createModifiers(eventArg),
757
- }),
899
+ }, optionsArg),
758
900
  }).catch(() => undefined);
759
901
  }
760
902
 
@@ -795,7 +937,7 @@ export class LiveBrowserCanvasRenderer {
795
937
  void this.enqueueInputCommand({
796
938
  kind: 'key',
797
939
  runEpoch: runEpochArg,
798
- execute: async () => this.client.dispatchKey(input),
940
+ execute: async (optionsArg) => this.client.dispatchKey(input, optionsArg),
799
941
  onAttempt: typeArg === 'down'
800
942
  ? () => this.pressedKeys.set(keyIdentity, input)
801
943
  : undefined,
@@ -916,34 +1058,44 @@ export class LiveBrowserCanvasRenderer {
916
1058
  try {
917
1059
  while (this.inputCommands.length > 0) {
918
1060
  const command = this.inputCommands.shift()!;
919
- if (command.runEpoch !== this.runEpoch) {
920
- command.resolve();
1061
+ if (!this.isRunActive(command.runEpoch)) {
1062
+ command.reject(new LiveBrowserRunAbortedError(
1063
+ `Live browser input belongs to inactive run ${command.runEpoch}`,
1064
+ ));
921
1065
  continue;
922
1066
  }
923
1067
  try {
924
1068
  command.onAttempt?.();
925
- await this.runClientOperation(command.execute, 'input dispatch');
1069
+ await this.runClientOperation(command.execute, 'input dispatch', command.runEpoch);
1070
+ if (!this.isRunActive(command.runEpoch)) {
1071
+ throw new LiveBrowserRunAbortedError(
1072
+ `Live browser input belongs to inactive run ${command.runEpoch}`,
1073
+ );
1074
+ }
926
1075
  command.onSuccess?.();
927
1076
  command.resolve();
928
1077
  } catch (error) {
1078
+ command.reject(error);
1079
+ if (error instanceof LiveBrowserRunAbortedError) {
1080
+ continue;
1081
+ }
1082
+ if (error instanceof LiveBrowserOperationTimeoutError) {
1083
+ this.requestSuspend(command.runEpoch);
1084
+ }
929
1085
  this.reportError({
930
1086
  code: 'input_dispatch_failed',
931
1087
  message: `Could not dispatch live browser input: ${getErrorMessage(error)}`,
932
1088
  cause: error,
933
1089
  }, command.runEpoch);
934
- command.reject(error);
935
1090
  const abandonedCommands = this.inputCommands.splice(0);
936
1091
  for (const abandonedCommand of abandonedCommands) {
937
1092
  abandonedCommand.reject(
938
1093
  new Error('Live browser input was abandoned after a dispatch failure'),
939
1094
  );
940
1095
  }
941
- if (!this.stopping) {
1096
+ if (!(error instanceof LiveBrowserOperationTimeoutError) && !this.stopping) {
942
1097
  void this.scheduleInputReset(false, command.runEpoch);
943
1098
  }
944
- if (error instanceof LiveBrowserOperationTimeoutError) {
945
- this.requestStop(command.runEpoch);
946
- }
947
1099
  }
948
1100
  }
949
1101
  } finally {
@@ -1017,13 +1169,13 @@ export class LiveBrowserCanvasRenderer {
1017
1169
  private async releasePressedInput(runEpochArg: number): Promise<void> {
1018
1170
  for (const [keyIdentity, pressedKey] of [...this.pressedKeys.entries()]) {
1019
1171
  const released = await this.tryInputRelease(
1020
- () => this.client.dispatchKey({
1172
+ (optionsArg) => this.client.dispatchKey({
1021
1173
  ...pressedKey,
1022
1174
  type: 'up',
1023
1175
  text: undefined,
1024
1176
  autoRepeat: false,
1025
1177
  modifiers: {},
1026
- }),
1178
+ }, optionsArg),
1027
1179
  runEpochArg,
1028
1180
  );
1029
1181
  if (released && this.pressedKeys.get(keyIdentity) === pressedKey) {
@@ -1032,13 +1184,13 @@ export class LiveBrowserCanvasRenderer {
1032
1184
  }
1033
1185
  for (const [button, pressedButton] of [...this.pressedMouseButtons.entries()]) {
1034
1186
  const released = await this.tryInputRelease(
1035
- () => this.client.dispatchMouse({
1187
+ (optionsArg) => this.client.dispatchMouse({
1036
1188
  ...pressedButton,
1037
1189
  type: 'up',
1038
1190
  buttons: 0,
1039
1191
  clickCount: 1,
1040
1192
  modifiers: {},
1041
- }),
1193
+ }, optionsArg),
1042
1194
  runEpochArg,
1043
1195
  );
1044
1196
  if (released && this.pressedMouseButtons.get(button) === pressedButton) {
@@ -1048,7 +1200,7 @@ export class LiveBrowserCanvasRenderer {
1048
1200
  }
1049
1201
 
1050
1202
  private async tryInputRelease(
1051
- operationArg: () => Promise<void>,
1203
+ operationArg: (optionsArg: ILiveBrowserCanvasOperationOptions) => Promise<void>,
1052
1204
  runEpochArg: number,
1053
1205
  ): Promise<boolean> {
1054
1206
  let lastError: unknown;
@@ -1057,14 +1209,18 @@ export class LiveBrowserCanvasRenderer {
1057
1209
  await this.runClientOperation(
1058
1210
  operationArg,
1059
1211
  'input release',
1212
+ runEpochArg,
1060
1213
  );
1061
1214
  return true;
1062
1215
  } catch (error) {
1216
+ if (error instanceof LiveBrowserRunAbortedError) {
1217
+ return false;
1218
+ }
1063
1219
  lastError = error;
1064
1220
  }
1065
1221
  }
1222
+ this.requestSuspend(runEpochArg);
1066
1223
  this.reportInputReleaseError(lastError, runEpochArg);
1067
- this.requestStop(runEpochArg);
1068
1224
  return false;
1069
1225
  }
1070
1226
 
@@ -1080,7 +1236,7 @@ export class LiveBrowserCanvasRenderer {
1080
1236
  if (
1081
1237
  !this.running
1082
1238
  || this.stopping
1083
- || this.stopRequested
1239
+ || this.interruptionRequested
1084
1240
  || runEpochArg !== this.runEpoch
1085
1241
  || widthArg <= 0
1086
1242
  || heightArg <= 0
@@ -1175,8 +1331,9 @@ export class LiveBrowserCanvasRenderer {
1175
1331
  const previousMinimumRevision = this.resizeFence?.minimumViewportRevision ?? currentRevision;
1176
1332
  try {
1177
1333
  await this.runClientOperation(
1178
- () => this.client.setViewport(viewport),
1334
+ (optionsArg) => this.client.setViewport(viewport, optionsArg),
1179
1335
  'viewport update',
1336
+ runEpochArg,
1180
1337
  );
1181
1338
  if (!this.running || this.stopping || runEpochArg !== this.runEpoch) {
1182
1339
  return;
@@ -1186,16 +1343,16 @@ export class LiveBrowserCanvasRenderer {
1186
1343
  minimumViewportRevision: Math.max(currentRevision, previousMinimumRevision) + 1,
1187
1344
  };
1188
1345
  } catch (error) {
1189
- if (runEpochArg === this.runEpoch) {
1346
+ if (runEpochArg === this.runEpoch && !(error instanceof LiveBrowserRunAbortedError)) {
1190
1347
  this.resizeFence = undefined;
1348
+ if (error instanceof LiveBrowserOperationTimeoutError) {
1349
+ this.requestSuspend(runEpochArg);
1350
+ }
1191
1351
  this.reportError({
1192
1352
  code: 'viewport_update_failed',
1193
1353
  message: `Could not update live browser viewport: ${getErrorMessage(error)}`,
1194
1354
  cause: error,
1195
1355
  }, runEpochArg);
1196
- if (error instanceof LiveBrowserOperationTimeoutError) {
1197
- this.requestStop(runEpochArg);
1198
- }
1199
1356
  }
1200
1357
  } finally {
1201
1358
  if (this.inFlightViewport && viewportEquals(this.inFlightViewport, viewport)) {
@@ -1216,8 +1373,6 @@ export class LiveBrowserCanvasRenderer {
1216
1373
  && !this.viewportProcessing
1217
1374
  && !this.resizeFence
1218
1375
  && this.inputResetCount === 0
1219
- && this.pressedKeys.size === 0
1220
- && this.pressedMouseButtons.size === 0
1221
1376
  && this.isDisplayedFrameCurrent()
1222
1377
  );
1223
1378
  }
@@ -1276,22 +1431,12 @@ export class LiveBrowserCanvasRenderer {
1276
1431
  }
1277
1432
  }
1278
1433
 
1279
- private requestStop(runEpochArg: number): void {
1280
- if (
1281
- runEpochArg !== this.runEpoch
1282
- || (!this.running && !this.stopping)
1283
- ) {
1284
- return;
1285
- }
1286
- this.terminallyStopped = true;
1287
- this.acceptingFrames = false;
1288
- this.inputBlocked = true;
1289
- this.pendingViewport = undefined;
1290
- if (this.stopping || this.stopRequested) {
1434
+ private requestSuspend(runEpochArg: number): void {
1435
+ if (!this.isRunActive(runEpochArg) || this.interruptionRequested) {
1291
1436
  return;
1292
1437
  }
1293
- this.stopRequested = true;
1294
- void this.stop();
1438
+ this.interruptRun(runEpochArg);
1439
+ void this.suspend();
1295
1440
  }
1296
1441
 
1297
1442
  private validateTimeout(timeoutArg: number, nameArg: string): number {
@@ -1302,68 +1447,257 @@ export class LiveBrowserCanvasRenderer {
1302
1447
  }
1303
1448
 
1304
1449
  private runClientOperation<T>(
1305
- operationArg: () => Promise<T>,
1450
+ operationArg: (optionsArg: ILiveBrowserCanvasOperationOptions) => Promise<T>,
1306
1451
  operationNameArg: string,
1452
+ runEpochArg: number,
1307
1453
  ): Promise<T> {
1308
- let operation: Promise<T>;
1309
- try {
1310
- operation = operationArg();
1311
- } catch (error) {
1312
- return Promise.reject(error);
1313
- }
1314
- return this.withTimeout(operation, this.operationTimeoutMs, operationNameArg);
1454
+ return this.runAbortableOperation(
1455
+ (signalArg) => operationArg({ signal: signalArg }),
1456
+ this.operationTimeoutMs,
1457
+ operationNameArg,
1458
+ runEpochArg,
1459
+ );
1315
1460
  }
1316
1461
 
1317
- private withTimeout<T>(
1318
- operationArg: Promise<T>,
1462
+ private runAbortableOperation<T>(
1463
+ operationArg: (signalArg: AbortSignal) => Promise<T>,
1319
1464
  timeoutMsArg: number,
1320
1465
  operationNameArg: string,
1466
+ runEpochArg: number,
1321
1467
  ): Promise<T> {
1322
- return new Promise<T>((resolve, reject) => {
1468
+ const runController = this.runController;
1469
+ if (
1470
+ !runController
1471
+ || !this.isRunActive(runEpochArg)
1472
+ || runController.signal.aborted
1473
+ ) {
1474
+ return Promise.reject(new LiveBrowserRunAbortedError(
1475
+ `Cannot start ${operationNameArg} for inactive run ${runEpochArg}`,
1476
+ ));
1477
+ }
1478
+
1479
+ const operationController = new AbortController();
1480
+ this.operationControllers.add(operationController);
1481
+ const abortFromRun = () => {
1482
+ const reason = runController.signal.reason instanceof Error
1483
+ ? runController.signal.reason
1484
+ : new LiveBrowserRunAbortedError(
1485
+ `Live browser renderer run ${runEpochArg} was interrupted`,
1486
+ );
1487
+ operationController.abort(reason);
1488
+ };
1489
+ runController.signal.addEventListener('abort', abortFromRun, { once: true });
1490
+ const timeout = globalThis.setTimeout(() => {
1491
+ operationController.abort(new LiveBrowserOperationTimeoutError(
1492
+ `${operationNameArg} timed out after ${timeoutMsArg}ms`,
1493
+ ));
1494
+ }, timeoutMsArg);
1495
+
1496
+ let abortOperation = () => undefined;
1497
+ const operation = new Promise<T>((resolve, reject) => {
1323
1498
  let settled = false;
1324
- const timeout = globalThis.setTimeout(() => {
1499
+ const settle = (callbackArg: () => void) => {
1325
1500
  if (!settled) {
1326
1501
  settled = true;
1327
- reject(new LiveBrowserOperationTimeoutError(
1328
- `${operationNameArg} timed out after ${timeoutMsArg}ms`,
1329
- ));
1502
+ callbackArg();
1330
1503
  }
1331
- }, timeoutMsArg);
1332
- operationArg.then(
1504
+ };
1505
+ abortOperation = () => {
1506
+ const reason = operationController.signal.reason instanceof Error
1507
+ ? operationController.signal.reason
1508
+ : new LiveBrowserRunAbortedError(`${operationNameArg} was aborted`);
1509
+ settle(() => reject(reason));
1510
+ };
1511
+ operationController.signal.addEventListener('abort', abortOperation, { once: true });
1512
+
1513
+ let operationResult: Promise<T>;
1514
+ try {
1515
+ operationResult = operationArg(operationController.signal);
1516
+ } catch (error) {
1517
+ settle(() => reject(error));
1518
+ return;
1519
+ }
1520
+ operationResult.then(
1333
1521
  (valueArg) => {
1334
- if (!settled) {
1335
- settled = true;
1336
- globalThis.clearTimeout(timeout);
1337
- resolve(valueArg);
1338
- }
1522
+ settle(() => resolve(valueArg));
1339
1523
  },
1340
1524
  (errorArg) => {
1341
- if (!settled) {
1342
- settled = true;
1343
- globalThis.clearTimeout(timeout);
1344
- reject(errorArg);
1345
- }
1525
+ settle(() => reject(errorArg));
1346
1526
  },
1347
1527
  );
1528
+ }).finally(() => {
1529
+ globalThis.clearTimeout(timeout);
1530
+ runController.signal.removeEventListener('abort', abortFromRun);
1531
+ operationController.signal.removeEventListener('abort', abortOperation);
1532
+ this.operationControllers.delete(operationController);
1348
1533
  });
1534
+ return operation;
1349
1535
  }
1350
1536
 
1351
- private async decodeFrame(blobArg: Blob): Promise<ImageBitmap> {
1537
+ private async decodeFrame(blobArg: Blob, runEpochArg: number): Promise<ImageBitmap> {
1352
1538
  let decodeOperation: Promise<ImageBitmap>;
1353
1539
  try {
1354
1540
  decodeOperation = createImageBitmap(blobArg);
1355
1541
  } catch (error) {
1356
1542
  throw error;
1357
1543
  }
1544
+ let rawDecodeSettlementPromise: Promise<void>;
1545
+ rawDecodeSettlementPromise = decodeOperation.then(
1546
+ () => undefined,
1547
+ () => undefined,
1548
+ ).finally(() => {
1549
+ this.rawDecodeSettlementPromises.delete(rawDecodeSettlementPromise);
1550
+ });
1551
+ this.rawDecodeSettlementPromises.add(rawDecodeSettlementPromise);
1358
1552
  try {
1359
- return await this.withTimeout(
1360
- decodeOperation,
1553
+ return await this.runAbortableOperation(
1554
+ async () => decodeOperation,
1361
1555
  this.frameDecodeTimeoutMs,
1362
1556
  'frame decode',
1557
+ runEpochArg,
1363
1558
  );
1364
1559
  } catch (error) {
1365
- void decodeOperation.then((lateBitmapArg) => lateBitmapArg.close(), () => undefined);
1366
- throw error;
1560
+ if (
1561
+ error instanceof LiveBrowserOperationTimeoutError
1562
+ || error instanceof LiveBrowserRunAbortedError
1563
+ ) {
1564
+ void decodeOperation.then((lateBitmapArg) => lateBitmapArg.close(), () => undefined);
1565
+ throw error;
1566
+ }
1567
+ throw new LiveBrowserFrameIntegrityError(
1568
+ `frame bytes could not be decoded: ${getErrorMessage(error)}`,
1569
+ { cause: error },
1570
+ );
1571
+ }
1572
+ }
1573
+
1574
+ private isRunActive(runEpochArg: number): boolean {
1575
+ return Boolean(
1576
+ this.running
1577
+ && !this.stopping
1578
+ && runEpochArg === this.runEpoch
1579
+ && this.runController
1580
+ && !this.runController.signal.aborted,
1581
+ );
1582
+ }
1583
+
1584
+ private validateFrame(frameArg: ILiveBrowserFrame): void {
1585
+ if (!frameArg || typeof frameArg !== 'object') {
1586
+ throw new LiveBrowserFrameProtocolError('frame must be an object');
1587
+ }
1588
+ if (
1589
+ typeof frameArg.tabId !== 'string'
1590
+ || frameArg.tabId.length === 0
1591
+ || frameArg.tabId.length > 4096
1592
+ ) {
1593
+ throw new LiveBrowserFrameProtocolError('frame.tabId must be a non-empty bounded string');
1594
+ }
1595
+ this.validateProtocolInteger(frameArg.sequence, 'frame.sequence', 1);
1596
+ this.validateProtocolInteger(frameArg.generation, 'frame.generation', 1);
1597
+ this.validateProtocolInteger(frameArg.viewportRevision, 'frame.viewportRevision', 1);
1598
+ this.validateViewport(frameArg.viewport, 'frame.viewport');
1599
+ if (frameArg.format !== 'jpeg' && frameArg.format !== 'png') {
1600
+ throw new LiveBrowserFrameProtocolError('frame.format must be jpeg or png');
1601
+ }
1602
+ const expectedMimeType = frameArg.format === 'jpeg' ? 'image/jpeg' : 'image/png';
1603
+ if (frameArg.mimeType !== expectedMimeType) {
1604
+ throw new LiveBrowserFrameProtocolError(
1605
+ `frame.mimeType must be ${expectedMimeType} for ${frameArg.format}`,
1606
+ );
1607
+ }
1608
+ this.validateProtocolInteger(frameArg.width, 'frame.width', 1, maxFrameDimension);
1609
+ this.validateProtocolInteger(frameArg.height, 'frame.height', 1, maxFrameDimension);
1610
+ if (frameArg.width * frameArg.height > maxFramePixelArea) {
1611
+ throw new LiveBrowserFrameProtocolError(
1612
+ `frame pixel area must not exceed ${maxFramePixelArea}`,
1613
+ );
1614
+ }
1615
+ if (!(frameArg.data instanceof Uint8Array)) {
1616
+ throw new LiveBrowserFrameProtocolError('frame.data must be a Uint8Array');
1617
+ }
1618
+ if (frameArg.data.byteLength === 0 || frameArg.data.byteLength > maxFrameByteLength) {
1619
+ throw new LiveBrowserFrameProtocolError(
1620
+ `frame.data byte length must be between 1 and ${maxFrameByteLength}`,
1621
+ );
1622
+ }
1623
+
1624
+ const metadata = frameArg.metadata;
1625
+ if (!metadata || typeof metadata !== 'object') {
1626
+ throw new LiveBrowserFrameProtocolError('frame.metadata must be an object');
1627
+ }
1628
+ this.validateProtocolNumber(metadata.offsetTop, 'frame.metadata.offsetTop');
1629
+ this.validateProtocolNumber(
1630
+ metadata.pageScaleFactor,
1631
+ 'frame.metadata.pageScaleFactor',
1632
+ true,
1633
+ );
1634
+ this.validateProtocolNumber(metadata.deviceWidth, 'frame.metadata.deviceWidth', true);
1635
+ this.validateProtocolNumber(metadata.deviceHeight, 'frame.metadata.deviceHeight', true);
1636
+ if (
1637
+ metadata.deviceWidth > maxFrameDimension
1638
+ || metadata.deviceHeight > maxFrameDimension
1639
+ || metadata.deviceWidth * metadata.deviceHeight > maxFramePixelArea
1640
+ ) {
1641
+ throw new LiveBrowserFrameProtocolError(
1642
+ `frame metadata dimensions must fit within ${maxFramePixelArea} pixels`,
1643
+ );
1644
+ }
1645
+ this.validateProtocolNumber(metadata.scrollOffsetX, 'frame.metadata.scrollOffsetX');
1646
+ this.validateProtocolNumber(metadata.scrollOffsetY, 'frame.metadata.scrollOffsetY');
1647
+ if (metadata.timestamp !== undefined) {
1648
+ this.validateProtocolNumber(metadata.timestamp, 'frame.metadata.timestamp');
1649
+ }
1650
+ }
1651
+
1652
+ private validateViewport(viewportArg: ILiveBrowserViewport, nameArg: string): void {
1653
+ if (!viewportArg || typeof viewportArg !== 'object') {
1654
+ throw new LiveBrowserFrameProtocolError(`${nameArg} must be an object`);
1655
+ }
1656
+ this.validateProtocolInteger(viewportArg.width, `${nameArg}.width`, 1, maxViewportWidth);
1657
+ this.validateProtocolInteger(viewportArg.height, `${nameArg}.height`, 1, maxViewportHeight);
1658
+ this.validateProtocolNumber(viewportArg.deviceScaleFactor, `${nameArg}.deviceScaleFactor`, true);
1659
+ if (viewportArg.deviceScaleFactor > maxDeviceScaleFactor) {
1660
+ throw new LiveBrowserFrameProtocolError(
1661
+ `${nameArg}.deviceScaleFactor must not exceed ${maxDeviceScaleFactor}`,
1662
+ );
1663
+ }
1664
+ const physicalArea = viewportArg.width
1665
+ * viewportArg.height
1666
+ * viewportArg.deviceScaleFactor
1667
+ * viewportArg.deviceScaleFactor;
1668
+ if (physicalArea > maxFramePixelArea) {
1669
+ throw new LiveBrowserFrameProtocolError(
1670
+ `${nameArg} physical pixel area must not exceed ${maxFramePixelArea}`,
1671
+ );
1672
+ }
1673
+ }
1674
+
1675
+ private validateProtocolInteger(
1676
+ valueArg: number,
1677
+ nameArg: string,
1678
+ minimumArg: number,
1679
+ maximumArg = Number.MAX_SAFE_INTEGER,
1680
+ ): void {
1681
+ if (
1682
+ !Number.isSafeInteger(valueArg)
1683
+ || valueArg < minimumArg
1684
+ || valueArg > maximumArg
1685
+ ) {
1686
+ throw new LiveBrowserFrameProtocolError(
1687
+ `${nameArg} must be an integer between ${minimumArg} and ${maximumArg}`,
1688
+ );
1689
+ }
1690
+ }
1691
+
1692
+ private validateProtocolNumber(
1693
+ valueArg: number,
1694
+ nameArg: string,
1695
+ positiveArg = false,
1696
+ ): void {
1697
+ if (!Number.isFinite(valueArg) || (positiveArg && valueArg <= 0)) {
1698
+ throw new LiveBrowserFrameProtocolError(
1699
+ `${nameArg} must be a ${positiveArg ? 'positive ' : ''}finite number`,
1700
+ );
1367
1701
  }
1368
1702
  }
1369
1703