@push.rocks/smartbrowser 3.0.0 → 4.0.1

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