agl 23.0.0 → 24.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.
Files changed (35) hide show
  1. package/changelog.md +11 -0
  2. package/dist_serve/bundle.js +811 -808
  3. package/dist_serve/bundle.js.map +1 -0
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/classes.authstore.js +2 -2
  6. package/dist_ts/classes.browserresourcehost.d.ts +1 -0
  7. package/dist_ts/classes.browserresourcehost.js +7 -1
  8. package/dist_ts/classes.controller.d.ts +35 -5
  9. package/dist_ts/classes.controller.js +994 -229
  10. package/dist_ts/classes.validation.d.ts +9 -1
  11. package/dist_ts/classes.validation.js +62 -7
  12. package/dist_ts_interfaces/browsertransport.d.ts +72 -0
  13. package/dist_ts_interfaces/browsertransport.js +47 -0
  14. package/dist_ts_interfaces/index.d.ts +1 -0
  15. package/dist_ts_interfaces/index.js +2 -1
  16. package/dist_ts_interfaces/interfaces.d.ts +10 -6
  17. package/dist_ts_interfaces/interfaces.js +2 -3
  18. package/dist_ts_interfaces/requests.d.ts +20 -21
  19. package/package.json +5 -5
  20. package/readme.md +7 -3
  21. package/ts/00_commitinfo_data.ts +1 -1
  22. package/ts/classes.authstore.ts +1 -1
  23. package/ts/classes.browserresourcehost.ts +7 -0
  24. package/ts/classes.controller.ts +1367 -279
  25. package/ts/classes.validation.ts +77 -6
  26. package/ts_interfaces/browsertransport.ts +126 -0
  27. package/ts_interfaces/index.ts +1 -0
  28. package/ts_interfaces/interfaces.ts +11 -11
  29. package/ts_interfaces/requests.ts +23 -30
  30. package/ts_web/00_commitinfo_data.ts +1 -1
  31. package/ts_web/classes.browserviewtransport.ts +532 -0
  32. package/ts_web/classes.controllersocketclient.ts +6 -17
  33. package/ts_web/elements.harnesscontrollerapp.ts +450 -115
  34. package/ts_web/index.ts +1 -0
  35. package/ts_web/plugins.ts +2 -0
@@ -12,6 +12,21 @@ import {
12
12
  controllerBuiltinCommands,
13
13
  controllerMaxToolEventBytes,
14
14
  controllerMaxLiveMessageEventBytes,
15
+ controllerBrowserTransportEventContentType,
16
+ controllerBrowserTransportMaximumActiveAcknowledgements,
17
+ controllerBrowserTransportMaximumFrameBytes,
18
+ controllerBrowserTransportMaximumJsonBytes,
19
+ controllerBrowserTransportMaximumPendingEventBytes,
20
+ controllerBrowserTransportMaximumPendingEvents,
21
+ controllerBrowserTransportMaximumPendingOperations,
22
+ controllerBrowserTransportMaximumQueuedOperationBytes,
23
+ controllerBrowserTransportMaximumRecoveryAttempts,
24
+ controllerBrowserTransportOperationContentType,
25
+ controllerBrowserTransportPairOpenTimeoutMs,
26
+ controllerBrowserTransportProtocol,
27
+ controllerBrowserTransportRecoveryGraceMs,
28
+ decodeControllerBrowserTransportPacket,
29
+ encodeControllerBrowserTransportPacket,
15
30
  controllerLiveMessageDeltaTargetBytes,
16
31
  controllerInitialMessageBundleLimit,
17
32
  controllerMaximumTranscriptBytes,
@@ -95,8 +110,15 @@ import {
95
110
  type IReq_ControllerBrowserViewOpen,
96
111
  type IReq_ControllerBrowserViewActivate,
97
112
  type IReq_ControllerBrowserViewClose,
98
- type IReq_ControllerBrowserViewOperate,
99
- type IReq_ControllerBrowserViewEvent,
113
+ type IReq_ControllerBrowserViewRecover,
114
+ type IControllerBrowserFrame,
115
+ type IControllerBrowserFrameMetadata,
116
+ type IControllerBrowserViewState,
117
+ type IControllerBrowserOperationPacket,
118
+ type IControllerBrowserOperationResultPacket,
119
+ type TControllerBrowserClientPacket,
120
+ type TControllerBrowserServerPacket,
121
+ type TControllerBrowserViewOperation,
100
122
  type IReq_ControllerProjectCreate,
101
123
  type IReq_ControllerProjectList,
102
124
  type IReq_ControllerProjectRemove,
@@ -242,7 +264,8 @@ import {
242
264
  assertBrowserViewOpenRequest,
243
265
  assertBrowserViewActivateRequest,
244
266
  assertBrowserViewCloseRequest,
245
- assertBrowserViewOperateRequest,
267
+ assertBrowserViewRecoverRequest,
268
+ assertControllerBrowserClientPacket,
246
269
  assertProjectCreateRequest,
247
270
  resolveProjectPathInput,
248
271
  assertProjectRemoveRequest,
@@ -332,9 +355,12 @@ const trustedProxySecretHeader = 'x-harness-controller-proxy-secret';
332
355
  const websocketPayloadLimit = 16 * 1024 * 1024;
333
356
  const browserViewActivationTimeoutMs = 15_000;
334
357
  const browserViewCleanupRetryMs = 1_000;
335
- const maxPendingBrowserViewEvents = 16;
336
- const maxPendingBrowserViewEventBytes = 16 * 1024 * 1024;
337
- const browserFrameStreamAuthorizationMarker = Symbol('controller.browser.frame.stream');
358
+ const browserViewTransportCleanupTimeoutMs = 9_000;
359
+ const browserViewRealtimePeerRate = 120;
360
+ const browserViewRealtimePeerBurst = 240;
361
+ const browserViewRealtimeGlobalRate = 512;
362
+ const browserViewRealtimeGlobalBurst = 1_024;
363
+ const browserStreamAuthorizationMarker = Symbol('controller.browser.stream');
338
364
  const browserSemanticOperationClassifications: ReadonlySet<
339
365
  plugins.browserRuntime.TBrowserRuntimeOperationClassification
340
366
  > = new Set(['navigation', 'tab', 'agent-action']);
@@ -476,22 +502,122 @@ interface ITerminalPeerLifecycleState {
476
502
  connected: boolean;
477
503
  }
478
504
 
505
+ class ControllerBrowserTokenBucket {
506
+ private tokens: number;
507
+ private updatedAt = performance.now();
508
+
509
+ constructor(
510
+ private readonly refillPerSecond: number,
511
+ private readonly capacity: number,
512
+ ) {
513
+ this.tokens = capacity;
514
+ }
515
+
516
+ public consume(nowArg = performance.now()): boolean {
517
+ const elapsedMs = Math.max(0, nowArg - this.updatedAt);
518
+ this.updatedAt = nowArg;
519
+ this.tokens = Math.min(
520
+ this.capacity,
521
+ this.tokens + ((elapsedMs / 1_000) * this.refillPerSecond),
522
+ );
523
+ if (this.tokens < 1) return false;
524
+ this.tokens -= 1;
525
+ return true;
526
+ }
527
+ }
528
+
529
+ type TControllerBrowserViewLifecycle =
530
+ | 'inactive'
531
+ | 'activating'
532
+ | 'active'
533
+ | 'recoverable'
534
+ | 'recovering'
535
+ | 'failing'
536
+ | 'closing';
537
+
538
+ type TControllerBrowserTransportLifecycle =
539
+ | 'opening'
540
+ | 'ready'
541
+ | 'active'
542
+ | 'failed'
543
+ | 'retiring'
544
+ | 'retired';
545
+
546
+ interface IControllerBrowserQueuedOperation {
547
+ operationId: number;
548
+ operation: TControllerBrowserViewOperation;
549
+ byteLength: number;
550
+ abortController?: AbortController;
551
+ cancelled: boolean;
552
+ terminal: boolean;
553
+ }
554
+
555
+ interface IControllerBrowserAcknowledgementOperation {
556
+ operationId: number;
557
+ terminal: boolean;
558
+ operation: Promise<void>;
559
+ }
560
+
561
+ interface IControllerBrowserViewTransport {
562
+ readonly identity: symbol;
563
+ generation: number;
564
+ lifecycle: TControllerBrowserTransportLifecycle;
565
+ events: plugins.typedrequestInterfaces.TVirtualStream<'send'>;
566
+ operations: plugins.typedrequestInterfaces.TVirtualStream<'receive'>;
567
+ abortController: AbortController;
568
+ openingTask: Promise<void>;
569
+ receiveTask?: Promise<void>;
570
+ drainTask?: Promise<void>;
571
+ deliveryTail: Promise<void>;
572
+ pendingEventCount: number;
573
+ pendingEventBytes: number;
574
+ operationQueue: IControllerBrowserQueuedOperation[];
575
+ queuedOperationBytes: number;
576
+ highestOperationId: number;
577
+ cancellableOperationIds: Set<number>;
578
+ activeMutation?: IControllerBrowserQueuedOperation;
579
+ activeAcknowledgements: Map<number, IControllerBrowserAcknowledgementOperation>;
580
+ retirementTask?: Promise<void>;
581
+ retirementSettlement?: Promise<void>;
582
+ retirementSettled?: boolean;
583
+ retirementDeadlineExpired?: boolean;
584
+ failure?: unknown;
585
+ }
586
+
587
+ interface IControllerBrowserTransportConstructionCleanup {
588
+ readonly settlement: Promise<void>;
589
+ readonly reason: unknown;
590
+ settled: boolean;
591
+ deadlineExpired: boolean;
592
+ }
593
+
479
594
  interface IControllerBrowserView {
480
595
  id: string;
481
596
  peerId: string;
597
+ credentialId: string;
482
598
  transportConnection: plugins.typedsocket.ISmartServeConnectionWrapper;
483
599
  transportPeer: plugins.typedsocket.ISmartServeConnectionWrapper['peer'];
484
600
  resource: IControllerResourceDocument;
485
601
  handle: Awaited<ReturnType<ControllerBrowserResourceHost['openHumanView']>>;
486
602
  authority: plugins.browserRuntime.TBrowserRuntimeLeaseAuthority;
487
- activationState: 'inactive' | 'activating' | 'active';
603
+ lifecycle: TControllerBrowserViewLifecycle;
488
604
  activationAbortController: AbortController;
489
605
  activationTimer?: ReturnType<typeof setTimeout>;
490
606
  activationPromise?: Promise<void>;
491
- deliveryTail: Promise<void>;
492
- pendingEventCount: number;
493
- pendingEventBytes: number;
494
- activeFrameStreams: Set<plugins.typedrequestInterfaces.TVirtualStream<'send'>>;
607
+ transport?: IControllerBrowserViewTransport;
608
+ pendingTransportIdentity?: symbol;
609
+ transportConstructionCleanup?: IControllerBrowserTransportConstructionCleanup;
610
+ streamGeneration: number;
611
+ stateRevision: number;
612
+ recoveryAttempts: number;
613
+ recoveryDeadlineAt?: number;
614
+ recoveryTimer?: ReturnType<typeof setTimeout>;
615
+ recoveryFrameGate?: {
616
+ tabId: string;
617
+ minimumGeneration: number;
618
+ viewportRevision: number;
619
+ admitted: boolean;
620
+ };
495
621
  closing: boolean;
496
622
  cleanupTask?: Promise<void>;
497
623
  cleanupRetryTimer?: ReturnType<typeof setTimeout>;
@@ -503,13 +629,15 @@ interface IControllerBrowserViewSlot {
503
629
  view?: IControllerBrowserView;
504
630
  }
505
631
 
506
- interface IControllerBrowserFrameStreamAuthorization {
507
- readonly marker: typeof browserFrameStreamAuthorizationMarker;
632
+ interface IControllerBrowserStreamAuthorization {
633
+ readonly marker: typeof browserStreamAuthorizationMarker;
508
634
  readonly view: IControllerBrowserView;
509
635
  readonly authority: plugins.browserRuntime.TBrowserRuntimeLeaseAuthority;
510
636
  readonly transportPeer: plugins.typedsocket.ISmartServeConnectionWrapper['peer'];
637
+ readonly transportIdentity: symbol;
638
+ readonly streamGeneration: number;
639
+ readonly creatorDirection: plugins.typedrequestInterfaces.TVirtualStreamDirection;
511
640
  readonly contentType: string;
512
- readonly integrity: plugins.typedrequestInterfaces.IVirtualStreamIntegrity;
513
641
  }
514
642
 
515
643
  interface IFlexRefreshJobCacheEntry {
@@ -1611,6 +1739,11 @@ export class OpenCodeController {
1611
1739
  private resourceCoordinator?: ControllerResourceCoordinator;
1612
1740
  private readonly browserViews = new Map<string, IControllerBrowserView>();
1613
1741
  private readonly browserViewSlots = new Map<string, IControllerBrowserViewSlot>();
1742
+ private readonly browserViewRealtimeBucketsByPeer = new Map<string, ControllerBrowserTokenBucket>();
1743
+ private readonly browserViewRealtimeGlobalBucket = new ControllerBrowserTokenBucket(
1744
+ browserViewRealtimeGlobalRate,
1745
+ browserViewRealtimeGlobalBurst,
1746
+ );
1614
1747
  private readonly authenticatedPeerIds = new Set<string>();
1615
1748
  private openCodeResourceGeneration = 0;
1616
1749
  private flexResourceGeneration = 0;
@@ -2059,7 +2192,7 @@ export class OpenCodeController {
2059
2192
  headersTimeout: 10_000,
2060
2193
  requestTimeout: 30_000,
2061
2194
  virtualStreamAuthorizationAdapter: {
2062
- bind: (authorizationArg, contextArg) => this.bindBrowserFrameStreamAuthorization(
2195
+ bind: (authorizationArg, contextArg) => this.bindBrowserStreamAuthorization(
2063
2196
  authorizationArg,
2064
2197
  contextArg,
2065
2198
  ),
@@ -3046,6 +3179,7 @@ export class OpenCodeController {
3046
3179
  (terminalPeerLifecycle as ITerminalPeerLifecycleState).connected = false;
3047
3180
  }
3048
3181
  this.authenticatedPeerIds.delete(peerArg.id);
3182
+ this.browserViewRealtimeBucketsByPeer.delete(peerArg.id);
3049
3183
  const cleanupResults = await Promise.allSettled([
3050
3184
  this.terminalManager?.detachPeer(peerArg.id) ?? Promise.resolve(),
3051
3185
  this.closeBrowserViewsForPeer(peerArg.id),
@@ -3243,6 +3377,7 @@ export class OpenCodeController {
3243
3377
  }
3244
3378
  this.resourceCoordinator = undefined;
3245
3379
  this.authenticatedPeerIds.clear();
3380
+ this.browserViewRealtimeBucketsByPeer.clear();
3246
3381
 
3247
3382
  await Promise.allSettled([
3248
3383
  ...(this.openCodeControllerEventDrainTask ? [this.openCodeControllerEventDrainTask] : []),
@@ -5808,43 +5943,52 @@ export class OpenCodeController {
5808
5943
  'controller.browser.view.activate',
5809
5944
  async (requestArg, toolsArg) => {
5810
5945
  const peer = peerFromLocalData(toolsArg?.localData);
5811
- const { viewId } = validateRequest(() => assertBrowserViewActivateRequest(requestArg));
5946
+ const { viewId, streamGeneration } = validateRequest(
5947
+ () => assertBrowserViewActivateRequest(requestArg),
5948
+ );
5812
5949
  return this.runOperation(peer, async (signalArg) => {
5813
- await this.activateBrowserView(viewId, peer.id, signalArg);
5950
+ await this.activateBrowserView(viewId, peer, streamGeneration, signalArg);
5814
5951
  return { activated: true as const };
5815
5952
  }, toolsArg?.abortSignal);
5816
5953
  },
5817
5954
  ));
5818
5955
 
5956
+ protectedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_ControllerBrowserViewRecover>(
5957
+ 'controller.browser.view.recover',
5958
+ async (requestArg, toolsArg) => {
5959
+ const peer = peerFromLocalData(toolsArg?.localData);
5960
+ const transportConnection = this.requireTypedServer().getServerConnectionForRequest(toolsArg);
5961
+ const parsed = validateRequest(() => assertBrowserViewRecoverRequest(requestArg));
5962
+ return this.runOperation(peer, async (signalArg) => this.recoverBrowserView(
5963
+ peer,
5964
+ transportConnection,
5965
+ parsed,
5966
+ signalArg,
5967
+ ), toolsArg?.abortSignal);
5968
+ },
5969
+ ));
5970
+
5819
5971
  protectedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_ControllerBrowserViewClose>(
5820
5972
  'controller.browser.view.close',
5821
5973
  async (requestArg, toolsArg) => {
5822
5974
  const peer = peerFromLocalData(toolsArg?.localData);
5823
- const { viewId } = validateRequest(() => assertBrowserViewCloseRequest(requestArg));
5975
+ const { viewId, expectedStreamGeneration } = validateRequest(
5976
+ () => assertBrowserViewCloseRequest(requestArg),
5977
+ );
5824
5978
  return this.runOperation(peer, async () => this.runAudited(
5825
5979
  { type: 'resource.browser.view.close', peerId: peer.id },
5826
5980
  async () => {
5827
- await this.closeBrowserView(viewId, peer.id);
5828
- return { closed: true as const };
5981
+ const closed = await this.closeBrowserView(
5982
+ viewId,
5983
+ peer,
5984
+ expectedStreamGeneration,
5985
+ );
5986
+ return { closed };
5829
5987
  },
5830
5988
  ));
5831
5989
  },
5832
5990
  ));
5833
5991
 
5834
- protectedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_ControllerBrowserViewOperate>(
5835
- 'controller.browser.view.operate',
5836
- async (requestArg, toolsArg) => {
5837
- const peer = peerFromLocalData(toolsArg?.localData);
5838
- const parsed = validateRequest(() => assertBrowserViewOperateRequest(requestArg));
5839
- return this.runOperation(peer, async (signalArg) => this.operateBrowserView(
5840
- peer.id,
5841
- parsed.viewId,
5842
- parsed.operation,
5843
- signalArg,
5844
- ), toolsArg?.abortSignal);
5845
- },
5846
- ));
5847
-
5848
5992
  protectedRouter.addTypedHandler(
5849
5993
  new plugins.typedrequest.TypedHandler<IReq_ControllerSessionGroupsGet>(
5850
5994
  'controller.sessiongroups.get',
@@ -13018,7 +13162,9 @@ export class OpenCodeController {
13018
13162
  transportConnectionArg: plugins.typedsocket.ISmartServeConnectionWrapper,
13019
13163
  requestArg: IReq_ControllerBrowserViewOpen['request'],
13020
13164
  signalArg: AbortSignal,
13021
- ): Promise<IReq_ControllerBrowserViewOpen['response']> {
13165
+ ): Promise<plugins.typedrequest.TReverseVirtualStreamDirections<
13166
+ IReq_ControllerBrowserViewOpen['response']
13167
+ >> {
13022
13168
  return this.runBrowserViewSlotOperation(peerArg.id, async (slotArg) => {
13023
13169
  const transportPeer = transportConnectionArg.peer;
13024
13170
  signalArg.throwIfAborted();
@@ -13075,17 +13221,17 @@ export class OpenCodeController {
13075
13221
  view = {
13076
13222
  id: viewId,
13077
13223
  peerId: peerArg.id,
13224
+ credentialId,
13078
13225
  transportConnection: transportConnectionArg,
13079
13226
  transportPeer,
13080
13227
  resource,
13081
13228
  handle,
13082
13229
  authority: handle.lease.getAuthority(),
13083
- activationState: 'inactive',
13230
+ lifecycle: 'inactive',
13084
13231
  activationAbortController: new AbortController(),
13085
- deliveryTail: Promise.resolve(),
13086
- pendingEventCount: 0,
13087
- pendingEventBytes: 0,
13088
- activeFrameStreams: new Set(),
13232
+ streamGeneration: 1,
13233
+ stateRevision: 0,
13234
+ recoveryAttempts: 0,
13089
13235
  closing: false,
13090
13236
  };
13091
13237
  slotArg.view = view;
@@ -13101,6 +13247,9 @@ export class OpenCodeController {
13101
13247
  );
13102
13248
  }
13103
13249
  this.browserViews.set(view.id, view);
13250
+ const transport = await this.createBrowserViewTransport(view, 1);
13251
+ this.beginBrowserViewTransportOpening(view, transport);
13252
+ signalArg.throwIfAborted();
13104
13253
  view.activationTimer = setTimeout(() => {
13105
13254
  this.scheduleBrowserViewClose(view!, new Error('Browser view activation timed out.'));
13106
13255
  }, browserViewActivationTimeoutMs);
@@ -13109,7 +13258,10 @@ export class OpenCodeController {
13109
13258
  viewId,
13110
13259
  resourceId: resource.id,
13111
13260
  attachmentRevision: resource.attachmentRevision,
13112
- state: this.toControllerBrowserViewState(handle.lease.getState()),
13261
+ streamGeneration: transport.generation,
13262
+ events: transport.events,
13263
+ operations: transport.operations,
13264
+ state: this.nextControllerBrowserViewState(view, handle.lease.getState()),
13113
13265
  };
13114
13266
  } catch (errorArg) {
13115
13267
  let cleanupError: unknown;
@@ -13135,43 +13287,53 @@ export class OpenCodeController {
13135
13287
 
13136
13288
  private async activateBrowserView(
13137
13289
  viewIdArg: string,
13138
- peerIdArg: string,
13290
+ peerArg: IControllerPeer,
13291
+ streamGenerationArg: number,
13139
13292
  signalArg: AbortSignal,
13140
13293
  ): Promise<void> {
13141
- await this.runBrowserViewSlotOperation(peerIdArg, async (slotArg) => {
13294
+ await this.runBrowserViewSlotOperation(peerArg.id, async (slotArg) => {
13142
13295
  const view = slotArg.view;
13296
+ const transport = view?.transport;
13143
13297
  if (
13144
13298
  !view
13145
13299
  || view.id !== viewIdArg
13146
13300
  || view.closing
13301
+ || view.transportPeer !== peerArg
13302
+ || view.credentialId !== peerArg.context.state[authenticatedPeerStateKey]
13303
+ || view.streamGeneration !== streamGenerationArg
13304
+ || !transport
13305
+ || transport.generation !== streamGenerationArg
13147
13306
  || this.browserViews.get(viewIdArg) !== view
13148
13307
  ) {
13149
13308
  throw new plugins.typedrequest.TypedResponseError('The browser view is unavailable.', {
13150
13309
  code: 'resource_unavailable',
13151
13310
  });
13152
13311
  }
13153
- if (view.activationState === 'active') return;
13312
+ await this.assertBrowserViewAuthority(
13313
+ view,
13314
+ streamGenerationArg,
13315
+ transport.identity,
13316
+ signalArg,
13317
+ );
13318
+ if (view.lifecycle === 'active' && transport.lifecycle === 'active') return;
13154
13319
  if (view.activationPromise) {
13155
13320
  await awaitWithAbortSignal(view.activationPromise, signalArg);
13156
13321
  return;
13157
13322
  }
13158
- view.activationState = 'activating';
13159
- const activation = view.handle.activate((eventArg) => {
13160
- if (view.closing || slotArg.view !== view) return;
13161
- this.queueBrowserViewEvent(view, eventArg);
13162
- });
13323
+ const recovering = view.lifecycle === 'recovering';
13324
+ if (!recovering && view.lifecycle !== 'inactive') {
13325
+ throw new plugins.typedrequest.TypedResponseError('The browser view cannot be activated.', {
13326
+ code: 'concurrent_change',
13327
+ });
13328
+ }
13329
+ if (!recovering) view.lifecycle = 'activating';
13330
+ const activation = this.activateBrowserViewTransport(view, transport, recovering, signalArg);
13163
13331
  view.activationPromise = activation;
13164
- const activationSignal = AbortSignal.any([
13165
- signalArg,
13166
- view.activationAbortController.signal,
13167
- ]);
13168
13332
  try {
13169
- await awaitWithAbortSignal(activation, activationSignal);
13170
- activationSignal.throwIfAborted();
13333
+ await activation;
13171
13334
  if (view.closing || slotArg.view !== view) {
13172
13335
  throw new Error('The browser view changed during activation.');
13173
13336
  }
13174
- view.activationState = 'active';
13175
13337
  if (view.activationTimer) clearTimeout(view.activationTimer);
13176
13338
  view.activationTimer = undefined;
13177
13339
  } catch (errorArg) {
@@ -13183,11 +13345,173 @@ export class OpenCodeController {
13183
13345
  });
13184
13346
  }
13185
13347
 
13186
- private async closeBrowserView(viewIdArg: string, peerIdArg: string): Promise<void> {
13187
- const slot = this.browserViewSlots.get(peerIdArg);
13348
+ private async activateBrowserViewTransport(
13349
+ viewArg: IControllerBrowserView,
13350
+ transportArg: IControllerBrowserViewTransport,
13351
+ recoveringArg: boolean,
13352
+ signalArg: AbortSignal,
13353
+ ): Promise<void> {
13354
+ const signals = [
13355
+ signalArg,
13356
+ viewArg.activationAbortController.signal,
13357
+ transportArg.abortController.signal,
13358
+ ];
13359
+ if (recoveringArg && viewArg.recoveryDeadlineAt !== undefined) {
13360
+ const remainingMs = viewArg.recoveryDeadlineAt - Date.now();
13361
+ if (remainingMs <= 0) throw new Error('Browser view recovery timed out.');
13362
+ signals.push(AbortSignal.timeout(remainingMs));
13363
+ }
13364
+ const activationSignal = AbortSignal.any(signals);
13365
+ await awaitWithAbortSignal(transportArg.openingTask, activationSignal);
13366
+ activationSignal.throwIfAborted();
13367
+ if (viewArg.transport !== transportArg || transportArg.lifecycle !== 'ready') {
13368
+ throw new Error('The browser view transport changed during activation.');
13369
+ }
13370
+ transportArg.lifecycle = 'active';
13371
+
13372
+ if (!recoveringArg) {
13373
+ await awaitWithAbortSignal(viewArg.handle.activate((eventArg) => {
13374
+ const currentTransport = viewArg.transport;
13375
+ if (viewArg.closing || !currentTransport) return;
13376
+ this.queueBrowserViewEvent(viewArg, currentTransport, eventArg);
13377
+ }), activationSignal);
13378
+ activationSignal.throwIfAborted();
13379
+ viewArg.lifecycle = 'active';
13380
+ this.ensureBrowserOperationDrain(viewArg, transportArg);
13381
+ return;
13382
+ }
13383
+
13384
+ const runtimeState = viewArg.handle.lease.getState();
13385
+ const activeTab = runtimeState.tabs.find((tabArg) => tabArg.id === runtimeState.activeTabId);
13386
+ if (!activeTab || !activeTab.active || activeTab.status !== 'open' || !activeTab.streaming) {
13387
+ throw new Error('The active browser tab cannot refresh its frame stream.');
13388
+ }
13389
+ const stateDelivery = this.queueBrowserServerPacket(viewArg, transportArg, {
13390
+ protocol: controllerBrowserTransportProtocol,
13391
+ streamGeneration: transportArg.generation,
13392
+ type: 'state',
13393
+ state: this.nextControllerBrowserViewState(viewArg, runtimeState),
13394
+ });
13395
+ if (!stateDelivery) throw new Error('The recovered browser state could not be queued.');
13396
+ await awaitWithAbortSignal(stateDelivery, activationSignal);
13397
+ viewArg.recoveryFrameGate = {
13398
+ tabId: activeTab.id,
13399
+ minimumGeneration: activeTab.generation + 1,
13400
+ viewportRevision: runtimeState.viewportRevision,
13401
+ admitted: false,
13402
+ };
13403
+ await viewArg.handle.refreshFrameStream(activationSignal);
13404
+ if (!viewArg.recoveryFrameGate.admitted) {
13405
+ throw new Error('BrowserRuntime did not deliver the recovered frame boundary.');
13406
+ }
13407
+ viewArg.recoveryFrameGate = undefined;
13408
+ viewArg.lifecycle = 'active';
13409
+ viewArg.recoveryDeadlineAt = undefined;
13410
+ if (viewArg.recoveryTimer) clearTimeout(viewArg.recoveryTimer);
13411
+ viewArg.recoveryTimer = undefined;
13412
+ this.ensureBrowserOperationDrain(viewArg, transportArg);
13413
+ }
13414
+
13415
+ private async recoverBrowserView(
13416
+ peerArg: IControllerPeer,
13417
+ transportConnectionArg: plugins.typedsocket.ISmartServeConnectionWrapper,
13418
+ requestArg: IReq_ControllerBrowserViewRecover['request'],
13419
+ signalArg: AbortSignal,
13420
+ ): Promise<plugins.typedrequest.TReverseVirtualStreamDirections<
13421
+ IReq_ControllerBrowserViewRecover['response']
13422
+ >> {
13423
+ return this.runBrowserViewSlotOperation(peerArg.id, async (slotArg) => {
13424
+ const view = slotArg.view;
13425
+ const oldTransport = view?.transport;
13426
+ if (
13427
+ !view
13428
+ || view.id !== requestArg.viewId
13429
+ || view.closing
13430
+ || view.transportConnection.peer !== transportConnectionArg.peer
13431
+ || view.transportPeer !== peerArg
13432
+ || view.credentialId !== peerArg.context.state[authenticatedPeerStateKey]
13433
+ || view.streamGeneration !== requestArg.expectedStreamGeneration
13434
+ || !oldTransport
13435
+ || oldTransport.generation !== requestArg.expectedStreamGeneration
13436
+ || (view.lifecycle !== 'active' && view.lifecycle !== 'recoverable')
13437
+ ) {
13438
+ throw new plugins.typedrequest.TypedResponseError('The browser view cannot be recovered.', {
13439
+ code: 'resource_unavailable',
13440
+ });
13441
+ }
13442
+ await this.assertBrowserViewAuthority(
13443
+ view,
13444
+ oldTransport.generation,
13445
+ oldTransport.identity,
13446
+ signalArg,
13447
+ );
13448
+ const now = Date.now();
13449
+ view.recoveryDeadlineAt ??= now + controllerBrowserTransportRecoveryGraceMs;
13450
+ if (
13451
+ now >= view.recoveryDeadlineAt
13452
+ || view.recoveryAttempts >= controllerBrowserTransportMaximumRecoveryAttempts
13453
+ ) {
13454
+ throw new plugins.typedrequest.TypedResponseError('Browser view recovery capacity was reached.', {
13455
+ code: 'operation_limit',
13456
+ });
13457
+ }
13458
+ view.recoveryAttempts += 1;
13459
+ view.lifecycle = 'recovering';
13460
+ this.armBrowserViewRecoveryDeadline(view);
13461
+ try {
13462
+ await this.retireBrowserViewTransport(
13463
+ view,
13464
+ oldTransport,
13465
+ Math.min(
13466
+ view.recoveryDeadlineAt,
13467
+ Date.now() + browserViewTransportCleanupTimeoutMs,
13468
+ ),
13469
+ );
13470
+ signalArg.throwIfAborted();
13471
+ if (Date.now() >= view.recoveryDeadlineAt) {
13472
+ throw new Error('Browser view recovery timed out.');
13473
+ }
13474
+ const streamGeneration = oldTransport.generation + 1;
13475
+ view.streamGeneration = streamGeneration;
13476
+ const transport = await this.createBrowserViewTransport(view, streamGeneration);
13477
+ this.beginBrowserViewTransportOpening(view, transport);
13478
+ signalArg.throwIfAborted();
13479
+ return {
13480
+ viewId: view.id,
13481
+ resourceId: view.resource.id,
13482
+ attachmentRevision: view.resource.attachmentRevision,
13483
+ streamGeneration,
13484
+ events: transport.events,
13485
+ operations: transport.operations,
13486
+ state: this.nextControllerBrowserViewState(view, view.handle.lease.getState()),
13487
+ };
13488
+ } catch (errorArg) {
13489
+ this.scheduleBrowserViewClose(view, errorArg);
13490
+ throw asTypedResponseError(errorArg);
13491
+ }
13492
+ });
13493
+ }
13494
+
13495
+ private async closeBrowserView(
13496
+ viewIdArg: string,
13497
+ peerArg: string | IControllerPeer,
13498
+ expectedStreamGenerationArg?: number,
13499
+ ): Promise<boolean> {
13500
+ const peerId = typeof peerArg === 'string' ? peerArg : peerArg.id;
13501
+ const slot = this.browserViewSlots.get(peerId);
13188
13502
  const view = slot?.view;
13189
- if (!slot || !view || view.id !== viewIdArg) return;
13503
+ if (
13504
+ !slot
13505
+ || !view
13506
+ || view.id !== viewIdArg
13507
+ || (typeof peerArg !== 'string' && view.transportPeer !== peerArg)
13508
+ || (
13509
+ expectedStreamGenerationArg !== undefined
13510
+ && view.streamGeneration !== expectedStreamGenerationArg
13511
+ )
13512
+ ) return false;
13190
13513
  await this.requestBrowserViewCleanup(slot, view, new Error('Browser view closed.'));
13514
+ return true;
13191
13515
  }
13192
13516
 
13193
13517
  private async closeBrowserViewsForPeer(peerIdArg: string): Promise<void> {
@@ -13261,15 +13585,22 @@ export class OpenCodeController {
13261
13585
  private markBrowserViewClosing(viewArg: IControllerBrowserView, reasonArg: unknown): void {
13262
13586
  if (!viewArg.closing) {
13263
13587
  viewArg.closing = true;
13588
+ viewArg.lifecycle = 'closing';
13264
13589
  if (this.browserViews.get(viewArg.id) === viewArg) this.browserViews.delete(viewArg.id);
13265
13590
  if (viewArg.activationTimer) clearTimeout(viewArg.activationTimer);
13266
13591
  viewArg.activationTimer = undefined;
13592
+ if (viewArg.recoveryTimer) clearTimeout(viewArg.recoveryTimer);
13593
+ viewArg.recoveryTimer = undefined;
13267
13594
  if (!viewArg.activationAbortController.signal.aborted) {
13268
13595
  viewArg.activationAbortController.abort(reasonArg);
13269
13596
  }
13270
13597
  }
13271
- for (const stream of viewArg.activeFrameStreams) {
13272
- void stream.abort(reasonArg).catch(() => undefined);
13598
+ const transport = viewArg.transport;
13599
+ if (transport) {
13600
+ if (!transport.abortController.signal.aborted) transport.abortController.abort(reasonArg);
13601
+ transport.activeMutation?.abortController?.abort(reasonArg);
13602
+ void transport.events.abort(reasonArg).catch(() => undefined);
13603
+ void transport.operations.abort(reasonArg).catch(() => undefined);
13273
13604
  }
13274
13605
  }
13275
13606
 
@@ -13334,14 +13665,18 @@ export class OpenCodeController {
13334
13665
  this.markBrowserViewClosing(viewArg, reasonArg);
13335
13666
  if (slotArg.view !== viewArg) return;
13336
13667
  const errors: unknown[] = [];
13337
- const streamResults = await Promise.allSettled(
13338
- [...viewArg.activeFrameStreams].map((stream) => stream.abort(reasonArg)),
13339
- );
13340
- errors.push(...streamResults
13341
- .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
13342
- .map((result) => result.reason));
13668
+ const constructionCleanup = viewArg.transportConstructionCleanup;
13669
+ if (constructionCleanup && !constructionCleanup.settled) {
13670
+ errors.push(new Error('Browser stream pair construction cleanup remains pending.'));
13671
+ }
13672
+ if (viewArg.transport) {
13673
+ await this.retireBrowserViewTransport(
13674
+ viewArg,
13675
+ viewArg.transport,
13676
+ Date.now() + browserViewTransportCleanupTimeoutMs,
13677
+ ).catch((errorArg) => errors.push(errorArg));
13678
+ }
13343
13679
  await viewArg.handle.close().catch((errorArg) => errors.push(errorArg));
13344
- await viewArg.deliveryTail.catch((errorArg) => errors.push(errorArg));
13345
13680
  if (errors.length === 1) throw errors[0];
13346
13681
  if (errors.length > 1) {
13347
13682
  throw new AggregateError(errors, 'Browser view cleanup is incomplete.');
@@ -13357,6 +13692,13 @@ export class OpenCodeController {
13357
13692
  reasonArg: unknown,
13358
13693
  ): void {
13359
13694
  if (slotArg.view !== viewArg || viewArg.cleanupRetryTimer) return;
13695
+ if (
13696
+ viewArg.transportConstructionCleanup
13697
+ || (
13698
+ viewArg.transport?.retirementSettlement
13699
+ && !viewArg.transport.retirementSettled
13700
+ )
13701
+ ) return;
13360
13702
  const timer = setTimeout(() => {
13361
13703
  if (viewArg.cleanupRetryTimer === timer) viewArg.cleanupRetryTimer = undefined;
13362
13704
  if (slotArg.view !== viewArg) return;
@@ -13368,250 +13710,932 @@ export class OpenCodeController {
13368
13710
  timer.unref?.();
13369
13711
  }
13370
13712
 
13371
- private async operateBrowserView(
13372
- peerIdArg: string,
13373
- viewIdArg: string,
13374
- operationArg: IReq_ControllerBrowserViewOperate['request']['operation'],
13375
- signalArg: AbortSignal,
13376
- ): Promise<IReq_ControllerBrowserViewOperate['response']> {
13377
- const view = this.browserViews.get(viewIdArg);
13378
- if (!view || view.peerId !== peerIdArg || view.closing) {
13379
- throw new plugins.typedrequest.TypedResponseError('The browser view is unavailable.', {
13380
- code: 'resource_unavailable',
13713
+ private queueBrowserViewEvent(
13714
+ viewArg: IControllerBrowserView,
13715
+ transportArg: IControllerBrowserViewTransport,
13716
+ eventArg: plugins.browserRuntime.TBrowserRuntimeEvent,
13717
+ ): void {
13718
+ try {
13719
+ if (viewArg.lifecycle === 'failing' || viewArg.lifecycle === 'closing') return;
13720
+ if (eventArg.type === 'state') {
13721
+ if (viewArg.lifecycle === 'recoverable') return;
13722
+ this.queueBrowserServerPacket(viewArg, transportArg, {
13723
+ protocol: controllerBrowserTransportProtocol,
13724
+ streamGeneration: transportArg.generation,
13725
+ type: 'state',
13726
+ state: this.nextControllerBrowserViewState(viewArg, eventArg.state),
13727
+ });
13728
+ return;
13729
+ }
13730
+ if (eventArg.type === 'error') {
13731
+ const delivery = this.queueBrowserServerPacket(viewArg, transportArg, {
13732
+ protocol: controllerBrowserTransportProtocol,
13733
+ streamGeneration: transportArg.generation,
13734
+ type: 'error',
13735
+ error: { ...eventArg.error, message: eventArg.error.code },
13736
+ });
13737
+ if (eventArg.error.fatal) {
13738
+ const reason = new Error(eventArg.error.code);
13739
+ viewArg.lifecycle = 'failing';
13740
+ this.terminalizeBrowserTransportOperations(transportArg, reason);
13741
+ if (!delivery) {
13742
+ this.scheduleBrowserViewClose(viewArg, reason);
13743
+ } else {
13744
+ void awaitWithAbortSignal(
13745
+ delivery,
13746
+ AbortSignal.timeout(browserViewTransportCleanupTimeoutMs),
13747
+ ).then(
13748
+ () => this.scheduleBrowserViewClose(viewArg, reason),
13749
+ () => this.scheduleBrowserViewClose(viewArg, reason),
13750
+ );
13751
+ }
13752
+ }
13753
+ return;
13754
+ }
13755
+ if (viewArg.lifecycle === 'recoverable') return;
13756
+ const gate = viewArg.recoveryFrameGate;
13757
+ if (viewArg.lifecycle === 'recovering') {
13758
+ if (
13759
+ !gate
13760
+ || eventArg.frame.tabId !== gate.tabId
13761
+ || eventArg.frame.generation < gate.minimumGeneration
13762
+ || eventArg.frame.viewportRevision !== gate.viewportRevision
13763
+ ) return;
13764
+ }
13765
+ const { data, ...frame } = eventArg.frame;
13766
+ if (data.byteLength > controllerBrowserTransportMaximumFrameBytes) {
13767
+ throw new Error('The browser frame exceeds the transport byte limit.');
13768
+ }
13769
+ const copiedData = Uint8Array.from(data);
13770
+ const metadata: IControllerBrowserFrameMetadata = {
13771
+ ...frame,
13772
+ dataByteLength: copiedData.byteLength,
13773
+ };
13774
+ this.queueBrowserServerPacket(viewArg, transportArg, {
13775
+ protocol: controllerBrowserTransportProtocol,
13776
+ streamGeneration: transportArg.generation,
13777
+ type: 'frame',
13778
+ frame: metadata,
13779
+ }, copiedData);
13780
+ if (gate) gate.admitted = true;
13781
+ } catch (errorArg) {
13782
+ this.scheduleBrowserViewClose(viewArg, errorArg);
13783
+ throw errorArg;
13784
+ }
13785
+ }
13786
+
13787
+ private async createBrowserViewTransport(
13788
+ viewArg: IControllerBrowserView,
13789
+ streamGenerationArg: number,
13790
+ ): Promise<IControllerBrowserViewTransport> {
13791
+ const typedSocket = this.typedServer?.typedsocket;
13792
+ if (!typedSocket) throw new Error('The controller transport is unavailable.');
13793
+ const transportIdentity = Symbol('controller.browser.transport');
13794
+ if (viewArg.pendingTransportIdentity || viewArg.transportConstructionCleanup) {
13795
+ throw new Error('A browser stream pair is already being constructed.');
13796
+ }
13797
+ viewArg.pendingTransportIdentity = transportIdentity;
13798
+ const createAuthorization = (
13799
+ creatorDirectionArg: plugins.typedrequestInterfaces.TVirtualStreamDirection,
13800
+ contentTypeArg: string,
13801
+ ): IControllerBrowserStreamAuthorization => Object.freeze({
13802
+ marker: browserStreamAuthorizationMarker,
13803
+ view: viewArg,
13804
+ authority: viewArg.authority,
13805
+ transportPeer: viewArg.transportPeer,
13806
+ transportIdentity,
13807
+ streamGeneration: streamGenerationArg,
13808
+ creatorDirection: creatorDirectionArg,
13809
+ contentType: contentTypeArg,
13810
+ });
13811
+ try {
13812
+ const events = typedSocket.createVirtualStream({
13813
+ target: viewArg.transportConnection,
13814
+ creatorDirection: 'send',
13815
+ contentType: controllerBrowserTransportEventContentType,
13816
+ authorization: createAuthorization('send', controllerBrowserTransportEventContentType),
13817
+ });
13818
+ let operations: plugins.typedrequestInterfaces.TVirtualStream<'receive'>;
13819
+ try {
13820
+ operations = typedSocket.createVirtualStream({
13821
+ target: viewArg.transportConnection,
13822
+ creatorDirection: 'receive',
13823
+ contentType: controllerBrowserTransportOperationContentType,
13824
+ authorization: createAuthorization('receive', controllerBrowserTransportOperationContentType),
13825
+ });
13826
+ } catch (errorArg) {
13827
+ let cleanup!: IControllerBrowserTransportConstructionCleanup;
13828
+ const settlement = Promise.allSettled([
13829
+ Promise.resolve().then(() => events.abort(errorArg)),
13830
+ events.opened,
13831
+ events.completion,
13832
+ events.closed,
13833
+ ]).then(() => {
13834
+ cleanup.settled = true;
13835
+ const slot = this.browserViewSlots.get(viewArg.peerId);
13836
+ if (
13837
+ !cleanup.deadlineExpired
13838
+ || !viewArg.closing
13839
+ || slot?.view !== viewArg
13840
+ ) {
13841
+ if (viewArg.transportConstructionCleanup === cleanup) {
13842
+ viewArg.transportConstructionCleanup = undefined;
13843
+ }
13844
+ return;
13845
+ }
13846
+ const activeCleanupTask = viewArg.cleanupTask;
13847
+ void (activeCleanupTask
13848
+ ? activeCleanupTask.catch(() => undefined)
13849
+ : Promise.resolve()
13850
+ ).then(() => {
13851
+ const currentSlot = this.browserViewSlots.get(viewArg.peerId);
13852
+ if (
13853
+ viewArg.transportConstructionCleanup !== cleanup
13854
+ || !viewArg.closing
13855
+ || currentSlot?.view !== viewArg
13856
+ ) return;
13857
+ viewArg.transportConstructionCleanup = undefined;
13858
+ void this.requestBrowserViewCleanup(currentSlot, viewArg, cleanup.reason).catch(
13859
+ (cleanupErrorArg) => {
13860
+ console.error(`Browser view ${viewArg.id} late construction cleanup failed.`, cleanupErrorArg);
13861
+ },
13862
+ );
13863
+ });
13864
+ });
13865
+ cleanup = {
13866
+ settlement,
13867
+ reason: errorArg,
13868
+ settled: false,
13869
+ deadlineExpired: false,
13870
+ };
13871
+ viewArg.transportConstructionCleanup = cleanup;
13872
+ try {
13873
+ await this.waitForBrowserTransportSettlement(
13874
+ settlement,
13875
+ Date.now() + browserViewTransportCleanupTimeoutMs,
13876
+ );
13877
+ } catch (cleanupErrorArg) {
13878
+ cleanup.deadlineExpired = true;
13879
+ throw new AggregateError(
13880
+ [errorArg, cleanupErrorArg],
13881
+ 'Browser stream pair construction cleanup is incomplete.',
13882
+ );
13883
+ }
13884
+ throw errorArg;
13885
+ }
13886
+ const transport: IControllerBrowserViewTransport = {
13887
+ identity: transportIdentity,
13888
+ generation: streamGenerationArg,
13889
+ lifecycle: 'opening',
13890
+ events,
13891
+ operations,
13892
+ abortController: new AbortController(),
13893
+ openingTask: Promise.resolve(),
13894
+ deliveryTail: Promise.resolve(),
13895
+ pendingEventCount: 0,
13896
+ pendingEventBytes: 0,
13897
+ operationQueue: [],
13898
+ queuedOperationBytes: 0,
13899
+ highestOperationId: 0,
13900
+ cancellableOperationIds: new Set(),
13901
+ activeAcknowledgements: new Map(),
13902
+ };
13903
+ viewArg.transport = transport;
13904
+ return transport;
13905
+ } finally {
13906
+ if (viewArg.pendingTransportIdentity === transportIdentity) {
13907
+ viewArg.pendingTransportIdentity = undefined;
13908
+ }
13909
+ }
13910
+ }
13911
+
13912
+ private beginBrowserViewTransportOpening(
13913
+ viewArg: IControllerBrowserView,
13914
+ transportArg: IControllerBrowserViewTransport,
13915
+ ): void {
13916
+ if (viewArg.transport !== transportArg) {
13917
+ throw new Error('The browser stream pair cannot open before publication.');
13918
+ }
13919
+ const openingTask = this.openBrowserViewTransport(viewArg, transportArg);
13920
+ transportArg.openingTask = openingTask;
13921
+ void openingTask.catch((errorArg) => {
13922
+ this.noteBrowserTransportFault(viewArg, transportArg, errorArg, true);
13923
+ });
13924
+ }
13925
+
13926
+ private async openBrowserViewTransport(
13927
+ viewArg: IControllerBrowserView,
13928
+ transportArg: IControllerBrowserViewTransport,
13929
+ ): Promise<void> {
13930
+ let timeout: ReturnType<typeof setTimeout> | undefined;
13931
+ try {
13932
+ await Promise.race([
13933
+ Promise.all([transportArg.events.opened, transportArg.operations.opened]),
13934
+ new Promise<never>((_resolve, reject) => {
13935
+ timeout = setTimeout(() => reject(new Error('Browser stream pair opening timed out.')),
13936
+ controllerBrowserTransportPairOpenTimeoutMs);
13937
+ timeout.unref?.();
13938
+ }),
13939
+ ]);
13940
+ if (
13941
+ viewArg.transport !== transportArg
13942
+ || viewArg.closing
13943
+ || viewArg.streamGeneration !== transportArg.generation
13944
+ || transportArg.lifecycle !== 'opening'
13945
+ ) throw new Error('The browser stream pair became stale while opening.');
13946
+ transportArg.lifecycle = 'ready';
13947
+ const receiveTask = this.receiveBrowserOperations(viewArg, transportArg);
13948
+ transportArg.receiveTask = receiveTask;
13949
+ void receiveTask.catch((errorArg) => {
13950
+ this.noteBrowserTransportFault(viewArg, transportArg, errorArg, true);
13381
13951
  });
13952
+ } finally {
13953
+ if (timeout) clearTimeout(timeout);
13382
13954
  }
13383
- const current = await awaitWithAbortSignal(
13384
- this.requireAuthStore().getResource(view.resource.projectId, view.resource.id),
13385
- signalArg,
13386
- );
13955
+ }
13956
+
13957
+ private async receiveBrowserOperations(
13958
+ viewArg: IControllerBrowserView,
13959
+ transportArg: IControllerBrowserViewTransport,
13960
+ ): Promise<void> {
13961
+ while (transportArg.lifecycle !== 'retiring' && transportArg.lifecycle !== 'retired') {
13962
+ const chunk = await transportArg.operations.receive();
13963
+ if (chunk === undefined) {
13964
+ if (transportArg.abortController.signal.aborted) return;
13965
+ await transportArg.operations.accept();
13966
+ throw new Error('The browser operation stream closed unexpectedly.');
13967
+ }
13968
+ let packet: TControllerBrowserClientPacket;
13969
+ try {
13970
+ packet = assertControllerBrowserClientPacket(
13971
+ decodeControllerBrowserTransportPacket(chunk),
13972
+ );
13973
+ } catch (errorArg) {
13974
+ this.failBrowserTransportProtocol(viewArg, transportArg, errorArg);
13975
+ return;
13976
+ }
13977
+ if (packet.streamGeneration !== transportArg.generation) {
13978
+ this.failBrowserTransportProtocol(
13979
+ viewArg,
13980
+ transportArg,
13981
+ new Error('The browser operation belongs to a stale stream generation.'),
13982
+ );
13983
+ return;
13984
+ }
13985
+ this.admitBrowserClientPacket(viewArg, transportArg, packet, chunk.byteLength);
13986
+ }
13987
+ }
13988
+
13989
+ private admitBrowserClientPacket(
13990
+ viewArg: IControllerBrowserView,
13991
+ transportArg: IControllerBrowserViewTransport,
13992
+ packetArg: TControllerBrowserClientPacket,
13993
+ byteLengthArg: number,
13994
+ ): void {
13995
+ if (packetArg.type === 'cancel') {
13996
+ if (packetArg.operationId > transportArg.highestOperationId) {
13997
+ this.failBrowserTransportProtocol(viewArg, transportArg, new Error('cancel targeted a future operation.'));
13998
+ return;
13999
+ }
14000
+ if (transportArg.cancellableOperationIds.has(packetArg.operationId)) {
14001
+ this.cancelBrowserOperation(viewArg, transportArg, packetArg.operationId);
14002
+ }
14003
+ return;
14004
+ }
14005
+ const admitted = this.consumeBrowserRealtimeAdmission(viewArg.peerId);
14006
+ if (!admitted) {
14007
+ if (packetArg.operationId <= transportArg.highestOperationId) {
14008
+ this.failBrowserTransportProtocol(viewArg, transportArg, new Error('operationId was reused.'));
14009
+ return;
14010
+ }
14011
+ transportArg.highestOperationId = packetArg.operationId;
14012
+ this.queueBrowserOperationResult(viewArg, transportArg, packetArg.operationId, {
14013
+ status: 'failed',
14014
+ error: { code: 'rate_limited', message: 'The browser input rate limit was reached.' },
14015
+ });
14016
+ return;
14017
+ }
14018
+ if (packetArg.operationId <= transportArg.highestOperationId) {
14019
+ this.failBrowserTransportProtocol(viewArg, transportArg, new Error('operationId was reused.'));
14020
+ return;
14021
+ }
14022
+ transportArg.highestOperationId = packetArg.operationId;
14023
+ const queued: IControllerBrowserQueuedOperation = {
14024
+ operationId: packetArg.operationId,
14025
+ operation: packetArg.operation,
14026
+ byteLength: byteLengthArg,
14027
+ cancelled: false,
14028
+ terminal: false,
14029
+ };
13387
14030
  if (
13388
- !current
13389
- || current.lifecycle !== 'active'
13390
- || current.kind !== 'browser'
13391
- || current.pendingAttachment
13392
- || current.attachmentAuthorityId !== view.resource.attachmentAuthorityId
13393
- || current.attachmentRevision !== view.resource.attachmentRevision
14031
+ transportArg.operationQueue.length >= controllerBrowserTransportMaximumPendingOperations
14032
+ || transportArg.queuedOperationBytes + byteLengthArg
14033
+ > controllerBrowserTransportMaximumQueuedOperationBytes
13394
14034
  ) {
13395
- await this.closeBrowserView(view.id, peerIdArg);
13396
- throw new plugins.typedrequest.TypedResponseError('The browser resource changed.', {
13397
- code: 'resource_unavailable',
14035
+ this.settleBrowserOperation(viewArg, transportArg, queued, {
14036
+ status: 'failed',
14037
+ error: { code: 'operation_limit', message: 'The browser operation queue is full.' },
13398
14038
  });
14039
+ return;
13399
14040
  }
13400
- const lease = view.handle.lease;
13401
- let accepted = true;
13402
- try {
13403
- switch (operationArg.type) {
13404
- case 'acknowledgeFrame':
13405
- signalArg.throwIfAborted();
13406
- accepted = await lease.acknowledgeFrame(operationArg.acknowledgement);
13407
- signalArg.throwIfAborted();
13408
- break;
13409
- case 'setViewport':
13410
- await lease.setViewport(operationArg.viewport, { signal: signalArg });
13411
- break;
13412
- case 'dispatchMouse':
13413
- await lease.dispatchMouse(operationArg.input, { signal: signalArg });
13414
- break;
13415
- case 'dispatchWheel':
13416
- await lease.dispatchWheel(operationArg.input, { signal: signalArg });
13417
- break;
13418
- case 'dispatchKey':
13419
- await lease.dispatchKey(operationArg.input, { signal: signalArg });
13420
- break;
13421
- case 'insertText':
13422
- await lease.insertText(operationArg.input, { signal: signalArg });
13423
- break;
13424
- case 'navigate':
13425
- await lease.executeHumanAction({
13426
- action: 'navigate',
13427
- url: operationArg.url,
13428
- ...(operationArg.tabId === undefined ? {} : { tabId: operationArg.tabId }),
13429
- ...(operationArg.timeoutMs === undefined ? {} : { timeoutMs: operationArg.timeoutMs }),
13430
- }, { signal: signalArg });
13431
- break;
13432
- case 'createTab':
13433
- await lease.createTab({
13434
- ...(operationArg.url === undefined ? {} : { url: operationArg.url }),
13435
- ...(operationArg.activate === undefined ? {} : { activate: operationArg.activate }),
13436
- }, { signal: signalArg });
13437
- break;
13438
- case 'activateTab':
13439
- await lease.activateTab(operationArg.tabId, { signal: signalArg });
13440
- break;
13441
- case 'closeTab':
13442
- await lease.closeTab(operationArg.tabId, { signal: signalArg });
13443
- break;
13444
- case 'back':
13445
- await lease.back(
13446
- operationArg.tabId === undefined ? {} : { tabId: operationArg.tabId },
13447
- { signal: signalArg },
13448
- );
13449
- break;
13450
- case 'forward':
13451
- await lease.forward(
13452
- operationArg.tabId === undefined ? {} : { tabId: operationArg.tabId },
13453
- { signal: signalArg },
13454
- );
13455
- break;
13456
- case 'reload':
13457
- await lease.reload(
13458
- operationArg.tabId === undefined ? {} : { tabId: operationArg.tabId },
13459
- { signal: signalArg },
13460
- );
13461
- break;
14041
+ transportArg.cancellableOperationIds.add(queued.operationId);
14042
+ const tail = transportArg.operationQueue.at(-1);
14043
+ if (tail && this.browserOperationsCanCoalesce(tail.operation, queued.operation)) {
14044
+ transportArg.operationQueue.pop();
14045
+ transportArg.queuedOperationBytes -= tail.byteLength;
14046
+ this.settleBrowserOperation(viewArg, transportArg, tail, { status: 'superseded' });
14047
+ }
14048
+ transportArg.operationQueue.push(queued);
14049
+ transportArg.queuedOperationBytes += byteLengthArg;
14050
+ if (viewArg.lifecycle === 'active' && transportArg.lifecycle === 'active') {
14051
+ if (queued.operation.type === 'acknowledgeFrame') {
14052
+ const index = transportArg.operationQueue.indexOf(queued);
14053
+ if (index >= 0) {
14054
+ transportArg.operationQueue.splice(index, 1);
14055
+ transportArg.queuedOperationBytes -= queued.byteLength;
14056
+ this.startBrowserAcknowledgement(viewArg, transportArg, queued);
14057
+ }
14058
+ } else {
14059
+ this.ensureBrowserOperationDrain(viewArg, transportArg);
13462
14060
  }
13463
- } catch (errorArg) {
13464
- throw asTypedResponseError(errorArg);
13465
14061
  }
13466
- return { accepted, state: this.toControllerBrowserViewState(lease.getState()) };
13467
14062
  }
13468
14063
 
13469
- private queueBrowserViewEvent(
14064
+ private consumeBrowserRealtimeAdmission(peerIdArg: string): boolean {
14065
+ let peerBucket = this.browserViewRealtimeBucketsByPeer.get(peerIdArg);
14066
+ if (!peerBucket) {
14067
+ peerBucket = new ControllerBrowserTokenBucket(
14068
+ browserViewRealtimePeerRate,
14069
+ browserViewRealtimePeerBurst,
14070
+ );
14071
+ this.browserViewRealtimeBucketsByPeer.set(peerIdArg, peerBucket);
14072
+ }
14073
+ const now = performance.now();
14074
+ return peerBucket.consume(now) && this.browserViewRealtimeGlobalBucket.consume(now);
14075
+ }
14076
+
14077
+ private browserOperationsCanCoalesce(
14078
+ existingArg: TControllerBrowserViewOperation,
14079
+ candidateArg: TControllerBrowserViewOperation,
14080
+ ): boolean {
14081
+ if (existingArg.type === 'setViewport' && candidateArg.type === 'setViewport') return true;
14082
+ return existingArg.type === 'dispatchMouse'
14083
+ && candidateArg.type === 'dispatchMouse'
14084
+ && existingArg.input.type === 'move'
14085
+ && candidateArg.input.type === 'move'
14086
+ && existingArg.input.tabId === candidateArg.input.tabId
14087
+ && existingArg.input.generation === candidateArg.input.generation
14088
+ && existingArg.input.viewportRevision === candidateArg.input.viewportRevision;
14089
+ }
14090
+
14091
+ private cancelBrowserOperation(
13470
14092
  viewArg: IControllerBrowserView,
13471
- eventArg: plugins.browserRuntime.TBrowserRuntimeEvent,
14093
+ transportArg: IControllerBrowserViewTransport,
14094
+ operationIdArg: number,
14095
+ ): void {
14096
+ if (!transportArg.cancellableOperationIds.delete(operationIdArg)) return;
14097
+ const queuedIndex = transportArg.operationQueue.findIndex(
14098
+ (candidateArg) => candidateArg.operationId === operationIdArg,
14099
+ );
14100
+ if (queuedIndex >= 0) {
14101
+ const [queued] = transportArg.operationQueue.splice(queuedIndex, 1);
14102
+ if (!queued) return;
14103
+ transportArg.queuedOperationBytes -= queued.byteLength;
14104
+ queued.cancelled = true;
14105
+ this.settleBrowserOperation(viewArg, transportArg, queued, { status: 'cancelled' });
14106
+ return;
14107
+ }
14108
+ const active = transportArg.activeMutation;
14109
+ if (active?.operationId === operationIdArg && !active.terminal) {
14110
+ active.cancelled = true;
14111
+ active.abortController?.abort(new Error('The browser operation was cancelled.'));
14112
+ return;
14113
+ }
14114
+ const acknowledgement = transportArg.activeAcknowledgements.get(operationIdArg);
14115
+ if (acknowledgement && !acknowledgement.terminal) {
14116
+ acknowledgement.terminal = true;
14117
+ this.queueBrowserOperationResult(viewArg, transportArg, operationIdArg, {
14118
+ status: 'cancelled',
14119
+ });
14120
+ }
14121
+ }
14122
+
14123
+ private ensureBrowserOperationDrain(
14124
+ viewArg: IControllerBrowserView,
14125
+ transportArg: IControllerBrowserViewTransport,
14126
+ ): void {
14127
+ if (
14128
+ transportArg.drainTask
14129
+ || viewArg.lifecycle !== 'active'
14130
+ || transportArg.lifecycle !== 'active'
14131
+ ) return;
14132
+ const drainTask = this.drainBrowserOperations(viewArg, transportArg).finally(() => {
14133
+ if (transportArg.drainTask === drainTask) transportArg.drainTask = undefined;
14134
+ if (
14135
+ viewArg.lifecycle === 'active'
14136
+ && transportArg.lifecycle === 'active'
14137
+ && transportArg.operationQueue.length > 0
14138
+ ) this.ensureBrowserOperationDrain(viewArg, transportArg);
14139
+ });
14140
+ transportArg.drainTask = drainTask;
14141
+ void drainTask.catch((errorArg) => {
14142
+ this.noteBrowserTransportFault(viewArg, transportArg, errorArg, false);
14143
+ });
14144
+ }
14145
+
14146
+ private async drainBrowserOperations(
14147
+ viewArg: IControllerBrowserView,
14148
+ transportArg: IControllerBrowserViewTransport,
14149
+ ): Promise<void> {
14150
+ while (
14151
+ viewArg.lifecycle === 'active'
14152
+ && transportArg.lifecycle === 'active'
14153
+ && viewArg.transport === transportArg
14154
+ ) {
14155
+ const queued = transportArg.operationQueue.shift();
14156
+ if (!queued) return;
14157
+ transportArg.queuedOperationBytes -= queued.byteLength;
14158
+ if (queued.terminal) continue;
14159
+ if (queued.operation.type === 'acknowledgeFrame') {
14160
+ this.startBrowserAcknowledgement(viewArg, transportArg, queued);
14161
+ continue;
14162
+ }
14163
+ transportArg.activeMutation = queued;
14164
+ queued.abortController = new AbortController();
14165
+ const peerAbortController = viewArg.transportPeer.context.state[
14166
+ operationAbortControllerPeerStateKey
14167
+ ];
14168
+ const operationSignal = AbortSignal.any([
14169
+ queued.abortController.signal,
14170
+ viewArg.activationAbortController.signal,
14171
+ transportArg.abortController.signal,
14172
+ this.operationAbortController.signal,
14173
+ ...(peerAbortController instanceof AbortController ? [peerAbortController.signal] : []),
14174
+ ]);
14175
+ try {
14176
+ const state = await this.runBrowserMutationOperation(
14177
+ viewArg,
14178
+ transportArg,
14179
+ queued.operation,
14180
+ operationSignal,
14181
+ );
14182
+ this.settleBrowserOperation(viewArg, transportArg, queued, {
14183
+ status: 'succeeded',
14184
+ ...(state === undefined ? {} : { state }),
14185
+ });
14186
+ } catch (errorArg) {
14187
+ this.settleBrowserOperation(
14188
+ viewArg,
14189
+ transportArg,
14190
+ queued,
14191
+ queued.cancelled || operationSignal.aborted
14192
+ ? { status: 'cancelled' }
14193
+ : { status: 'failed', error: this.browserOperationError(errorArg) },
14194
+ );
14195
+ } finally {
14196
+ if (transportArg.activeMutation === queued) transportArg.activeMutation = undefined;
14197
+ }
14198
+ }
14199
+ }
14200
+
14201
+ private async runBrowserMutationOperation(
14202
+ viewArg: IControllerBrowserView,
14203
+ transportArg: IControllerBrowserViewTransport,
14204
+ operationArg: Exclude<TControllerBrowserViewOperation, { type: 'acknowledgeFrame' }>,
14205
+ signalArg: AbortSignal,
14206
+ ): Promise<IControllerBrowserViewState | undefined> {
14207
+ const execute = async (operationSignalArg: AbortSignal) => {
14208
+ await this.assertBrowserViewAuthority(
14209
+ viewArg,
14210
+ transportArg.generation,
14211
+ transportArg.identity,
14212
+ operationSignalArg,
14213
+ );
14214
+ await this.executeBrowserMutation(viewArg, operationArg, operationSignalArg);
14215
+ };
14216
+ if (this.isBrowserSemanticOperation(operationArg)) {
14217
+ await this.runOperation(viewArg.transportPeer, execute, signalArg);
14218
+ return this.nextControllerBrowserViewState(viewArg, viewArg.handle.lease.getState());
14219
+ }
14220
+ if (this.lifecycleState !== 'ready' || this.stopRequested) {
14221
+ throw new Error('The controller is not ready to accept browser input.');
14222
+ }
14223
+ await execute(signalArg);
14224
+ return undefined;
14225
+ }
14226
+
14227
+ private isBrowserSemanticOperation(operationArg: TControllerBrowserViewOperation): boolean {
14228
+ return operationArg.type === 'navigate'
14229
+ || operationArg.type === 'createTab'
14230
+ || operationArg.type === 'activateTab'
14231
+ || operationArg.type === 'closeTab'
14232
+ || operationArg.type === 'back'
14233
+ || operationArg.type === 'forward'
14234
+ || operationArg.type === 'reload';
14235
+ }
14236
+
14237
+ private async executeBrowserMutation(
14238
+ viewArg: IControllerBrowserView,
14239
+ operationArg: Exclude<TControllerBrowserViewOperation, { type: 'acknowledgeFrame' }>,
14240
+ signalArg: AbortSignal,
14241
+ ): Promise<void> {
14242
+ const lease = viewArg.handle.lease;
14243
+ switch (operationArg.type) {
14244
+ case 'setViewport':
14245
+ await lease.setViewport(operationArg.viewport, { signal: signalArg });
14246
+ return;
14247
+ case 'dispatchMouse':
14248
+ await lease.dispatchMouse(operationArg.input, { signal: signalArg });
14249
+ return;
14250
+ case 'dispatchWheel':
14251
+ await lease.dispatchWheel(operationArg.input, { signal: signalArg });
14252
+ return;
14253
+ case 'dispatchKey':
14254
+ await lease.dispatchKey(operationArg.input, { signal: signalArg });
14255
+ return;
14256
+ case 'insertText':
14257
+ await lease.insertText(operationArg.input, { signal: signalArg });
14258
+ return;
14259
+ case 'navigate':
14260
+ await lease.executeHumanAction({
14261
+ action: 'navigate',
14262
+ url: operationArg.url,
14263
+ ...(operationArg.tabId === undefined ? {} : { tabId: operationArg.tabId }),
14264
+ ...(operationArg.timeoutMs === undefined ? {} : { timeoutMs: operationArg.timeoutMs }),
14265
+ }, { signal: signalArg });
14266
+ return;
14267
+ case 'createTab':
14268
+ await lease.createTab({
14269
+ ...(operationArg.url === undefined ? {} : { url: operationArg.url }),
14270
+ ...(operationArg.activate === undefined ? {} : { activate: operationArg.activate }),
14271
+ }, { signal: signalArg });
14272
+ return;
14273
+ case 'activateTab':
14274
+ await lease.activateTab(operationArg.tabId, { signal: signalArg });
14275
+ return;
14276
+ case 'closeTab':
14277
+ await lease.closeTab(operationArg.tabId, { signal: signalArg });
14278
+ return;
14279
+ case 'back':
14280
+ await lease.back(
14281
+ operationArg.tabId === undefined ? {} : { tabId: operationArg.tabId },
14282
+ { signal: signalArg },
14283
+ );
14284
+ return;
14285
+ case 'forward':
14286
+ await lease.forward(
14287
+ operationArg.tabId === undefined ? {} : { tabId: operationArg.tabId },
14288
+ { signal: signalArg },
14289
+ );
14290
+ return;
14291
+ case 'reload':
14292
+ await lease.reload(
14293
+ operationArg.tabId === undefined ? {} : { tabId: operationArg.tabId },
14294
+ { signal: signalArg },
14295
+ );
14296
+ }
14297
+ }
14298
+
14299
+ private startBrowserAcknowledgement(
14300
+ viewArg: IControllerBrowserView,
14301
+ transportArg: IControllerBrowserViewTransport,
14302
+ queuedArg: IControllerBrowserQueuedOperation,
13472
14303
  ): void {
13473
- const eventBytes = eventArg.type === 'frame' ? eventArg.frame.data.byteLength : 0;
14304
+ const operationRequest = queuedArg.operation;
14305
+ if (operationRequest.type !== 'acknowledgeFrame') return;
13474
14306
  if (
13475
- viewArg.pendingEventCount >= maxPendingBrowserViewEvents
13476
- || eventBytes > plugins.typedsocket.VIRTUAL_STREAM_MAX_LOGICAL_CHUNK_BYTES
13477
- || viewArg.pendingEventBytes + eventBytes > maxPendingBrowserViewEventBytes
14307
+ transportArg.activeAcknowledgements.size
14308
+ >= controllerBrowserTransportMaximumActiveAcknowledgements
13478
14309
  ) {
13479
- this.scheduleBrowserViewClose(viewArg, new Error('The browser view delivery queue is full.'));
14310
+ this.settleBrowserOperation(viewArg, transportArg, queuedArg, {
14311
+ status: 'failed',
14312
+ error: { code: 'operation_limit', message: 'Frame acknowledgement capacity was reached.' },
14313
+ });
13480
14314
  return;
13481
14315
  }
13482
- const queuedEvent: plugins.browserRuntime.TBrowserRuntimeEvent = eventArg.type === 'frame'
13483
- ? { type: 'frame', frame: { ...eventArg.frame, data: Uint8Array.from(eventArg.frame.data) } }
13484
- : eventArg;
13485
- viewArg.pendingEventCount += 1;
13486
- viewArg.pendingEventBytes += eventBytes;
13487
- const delivery = viewArg.deliveryTail.then(async () => {
13488
- if (viewArg.closing || this.browserViews.get(viewArg.id) !== viewArg) return;
13489
- await this.sendBrowserViewEvent(viewArg, queuedEvent);
13490
- }).catch((errorArg) => {
13491
- this.scheduleBrowserViewClose(viewArg, errorArg);
14316
+ const acknowledgement: IControllerBrowserAcknowledgementOperation = {
14317
+ operationId: queuedArg.operationId,
14318
+ terminal: false,
14319
+ operation: Promise.resolve(),
14320
+ };
14321
+ transportArg.activeAcknowledgements.set(queuedArg.operationId, acknowledgement);
14322
+ const operation = (async () => {
14323
+ try {
14324
+ await this.assertBrowserViewAuthority(
14325
+ viewArg,
14326
+ transportArg.generation,
14327
+ transportArg.identity,
14328
+ transportArg.abortController.signal,
14329
+ );
14330
+ const accepted = await viewArg.handle.lease.acknowledgeFrame(
14331
+ operationRequest.acknowledgement,
14332
+ );
14333
+ if (!acknowledgement.terminal) {
14334
+ acknowledgement.terminal = true;
14335
+ this.queueBrowserOperationResult(viewArg, transportArg, queuedArg.operationId, {
14336
+ status: 'succeeded',
14337
+ accepted,
14338
+ });
14339
+ }
14340
+ } catch (errorArg) {
14341
+ if (!acknowledgement.terminal) {
14342
+ acknowledgement.terminal = true;
14343
+ this.queueBrowserOperationResult(viewArg, transportArg, queuedArg.operationId, {
14344
+ status: 'failed',
14345
+ error: this.browserOperationError(errorArg),
14346
+ });
14347
+ }
14348
+ } finally {
14349
+ transportArg.activeAcknowledgements.delete(queuedArg.operationId);
14350
+ }
14351
+ })();
14352
+ acknowledgement.operation = operation;
14353
+ }
14354
+
14355
+ private settleBrowserOperation(
14356
+ viewArg: IControllerBrowserView,
14357
+ transportArg: IControllerBrowserViewTransport,
14358
+ queuedArg: IControllerBrowserQueuedOperation,
14359
+ resultArg: IControllerBrowserOperationResultPacket['result'],
14360
+ ): void {
14361
+ if (queuedArg.terminal) return;
14362
+ queuedArg.terminal = true;
14363
+ this.queueBrowserOperationResult(viewArg, transportArg, queuedArg.operationId, resultArg);
14364
+ }
14365
+
14366
+ private queueBrowserOperationResult(
14367
+ viewArg: IControllerBrowserView,
14368
+ transportArg: IControllerBrowserViewTransport,
14369
+ operationIdArg: number,
14370
+ resultArg: IControllerBrowserOperationResultPacket['result'],
14371
+ ): void {
14372
+ transportArg.cancellableOperationIds.delete(operationIdArg);
14373
+ try {
14374
+ this.queueBrowserServerPacket(viewArg, transportArg, {
14375
+ protocol: controllerBrowserTransportProtocol,
14376
+ streamGeneration: transportArg.generation,
14377
+ type: 'operationResult',
14378
+ operationId: operationIdArg,
14379
+ result: resultArg,
14380
+ });
14381
+ } catch (errorArg) {
14382
+ this.noteBrowserTransportFault(viewArg, transportArg, errorArg, false);
14383
+ }
14384
+ }
14385
+
14386
+ private queueBrowserServerPacket(
14387
+ viewArg: IControllerBrowserView,
14388
+ transportArg: IControllerBrowserViewTransport,
14389
+ packetArg: TControllerBrowserServerPacket,
14390
+ rawFrameArg?: Uint8Array,
14391
+ ): Promise<void> | undefined {
14392
+ if (
14393
+ viewArg.closing
14394
+ || viewArg.transport !== transportArg
14395
+ || !this.browserTransportCanDeliverPacket(transportArg, packetArg)
14396
+ ) return undefined;
14397
+ const packetBytes = encodeControllerBrowserTransportPacket(packetArg);
14398
+ const eventBytes = packetBytes.byteLength + (rawFrameArg?.byteLength ?? 0);
14399
+ if (
14400
+ transportArg.pendingEventCount >= controllerBrowserTransportMaximumPendingEvents
14401
+ || transportArg.pendingEventBytes + eventBytes
14402
+ > controllerBrowserTransportMaximumPendingEventBytes
14403
+ ) throw new Error('The browser view delivery queue is full.');
14404
+ transportArg.pendingEventCount += 1;
14405
+ transportArg.pendingEventBytes += eventBytes;
14406
+ const delivery = transportArg.deliveryTail.then(async () => {
14407
+ if (
14408
+ viewArg.closing
14409
+ || viewArg.transport !== transportArg
14410
+ || !this.browserTransportCanDeliverPacket(transportArg, packetArg)
14411
+ ) return;
14412
+ await transportArg.events.send(packetBytes);
14413
+ if (rawFrameArg) await transportArg.events.send(rawFrameArg);
14414
+ });
14415
+ transportArg.deliveryTail = delivery.then(() => undefined, () => undefined);
14416
+ void delivery.catch((errorArg) => {
14417
+ this.noteBrowserTransportFault(viewArg, transportArg, errorArg, true);
13492
14418
  }).finally(() => {
13493
- viewArg.pendingEventCount -= 1;
13494
- viewArg.pendingEventBytes -= eventBytes;
14419
+ transportArg.pendingEventCount -= 1;
14420
+ transportArg.pendingEventBytes -= eventBytes;
13495
14421
  });
13496
- viewArg.deliveryTail = delivery;
14422
+ return delivery;
13497
14423
  }
13498
14424
 
13499
- private async sendBrowserViewEvent(
14425
+ private browserTransportCanDeliverPacket(
14426
+ transportArg: IControllerBrowserViewTransport,
14427
+ packetArg: TControllerBrowserServerPacket,
14428
+ ): boolean {
14429
+ return transportArg.lifecycle === 'active'
14430
+ || (transportArg.lifecycle === 'ready' && packetArg.type === 'operationResult');
14431
+ }
14432
+
14433
+ private failBrowserTransportProtocol(
13500
14434
  viewArg: IControllerBrowserView,
13501
- eventArg: plugins.browserRuntime.TBrowserRuntimeEvent,
14435
+ transportArg: IControllerBrowserViewTransport,
14436
+ reasonArg: unknown,
14437
+ ): void {
14438
+ if (transportArg.lifecycle === 'retiring' || transportArg.lifecycle === 'retired') return;
14439
+ transportArg.lifecycle = 'failed';
14440
+ transportArg.failure = reasonArg;
14441
+ this.terminalizeBrowserTransportOperations(transportArg, reasonArg);
14442
+ void transportArg.events.abort(reasonArg).catch(() => undefined);
14443
+ void transportArg.operations.abort(reasonArg).catch(() => undefined);
14444
+ if (viewArg.transport !== transportArg) return;
14445
+ this.scheduleBrowserViewClose(viewArg, reasonArg);
14446
+ }
14447
+
14448
+ private noteBrowserTransportFault(
14449
+ viewArg: IControllerBrowserView,
14450
+ transportArg: IControllerBrowserViewTransport,
14451
+ reasonArg: unknown,
14452
+ recoverableArg: boolean,
14453
+ ): void {
14454
+ if (
14455
+ transportArg.lifecycle === 'failed'
14456
+ || transportArg.lifecycle === 'retiring'
14457
+ || transportArg.lifecycle === 'retired'
14458
+ ) return;
14459
+ transportArg.lifecycle = 'failed';
14460
+ transportArg.failure = reasonArg;
14461
+ this.terminalizeBrowserTransportOperations(transportArg, reasonArg);
14462
+ void transportArg.events.abort(reasonArg).catch(() => undefined);
14463
+ void transportArg.operations.abort(reasonArg).catch(() => undefined);
14464
+ if (viewArg.transport !== transportArg) return;
14465
+ if (
14466
+ recoverableArg
14467
+ && !viewArg.closing
14468
+ && viewArg.transport === transportArg
14469
+ && viewArg.lifecycle === 'active'
14470
+ ) {
14471
+ viewArg.lifecycle = 'recoverable';
14472
+ viewArg.recoveryDeadlineAt = Date.now() + controllerBrowserTransportRecoveryGraceMs;
14473
+ this.armBrowserViewRecoveryDeadline(viewArg);
14474
+ return;
14475
+ }
14476
+ this.scheduleBrowserViewClose(viewArg, reasonArg);
14477
+ }
14478
+
14479
+ private terminalizeBrowserTransportOperations(
14480
+ transportArg: IControllerBrowserViewTransport,
14481
+ reasonArg: unknown,
14482
+ ): void {
14483
+ if (!transportArg.abortController.signal.aborted) {
14484
+ transportArg.abortController.abort(reasonArg);
14485
+ }
14486
+ for (const queued of transportArg.operationQueue.splice(0)) queued.terminal = true;
14487
+ transportArg.queuedOperationBytes = 0;
14488
+ transportArg.cancellableOperationIds.clear();
14489
+ if (transportArg.activeMutation) {
14490
+ transportArg.activeMutation.cancelled = true;
14491
+ transportArg.activeMutation.abortController?.abort(reasonArg);
14492
+ }
14493
+ for (const acknowledgement of transportArg.activeAcknowledgements.values()) {
14494
+ acknowledgement.terminal = true;
14495
+ }
14496
+ }
14497
+
14498
+ private armBrowserViewRecoveryDeadline(viewArg: IControllerBrowserView): void {
14499
+ if (viewArg.recoveryTimer || viewArg.recoveryDeadlineAt === undefined) return;
14500
+ const delayMs = Math.max(0, viewArg.recoveryDeadlineAt - Date.now());
14501
+ const timer = setTimeout(() => {
14502
+ if (viewArg.recoveryTimer === timer) viewArg.recoveryTimer = undefined;
14503
+ if (viewArg.closing || viewArg.lifecycle === 'active') return;
14504
+ this.scheduleBrowserViewClose(viewArg, new Error('Browser view recovery timed out.'));
14505
+ }, delayMs);
14506
+ viewArg.recoveryTimer = timer;
14507
+ timer.unref?.();
14508
+ }
14509
+
14510
+ private retireBrowserViewTransport(
14511
+ viewArg: IControllerBrowserView,
14512
+ transportArg: IControllerBrowserViewTransport,
14513
+ deadlineAtArg: number,
13502
14514
  ): Promise<void> {
13503
- const typedSocket = this.typedServer?.typedsocket;
13504
- if (!typedSocket) throw new Error('The controller transport is unavailable.');
13505
- if (eventArg.type !== 'frame') {
13506
- const event: IReq_ControllerBrowserViewEvent['request']['event'] = eventArg.type === 'error'
13507
- ? {
13508
- type: 'error',
13509
- error: { ...eventArg.error, message: eventArg.error.code },
14515
+ if (transportArg.lifecycle === 'retired') return Promise.resolve();
14516
+ if (transportArg.retirementTask) return transportArg.retirementTask;
14517
+ if (transportArg.retirementSettlement && !transportArg.retirementSettled) {
14518
+ return Promise.reject(new Error('Browser stream generation cleanup remains pending.'));
14519
+ }
14520
+ if (!transportArg.retirementSettlement) {
14521
+ transportArg.lifecycle = 'retiring';
14522
+ const retirementReason = new Error('The browser stream generation was retired.');
14523
+ this.terminalizeBrowserTransportOperations(transportArg, retirementReason);
14524
+ const settlement = Promise.allSettled([
14525
+ Promise.resolve().then(() => transportArg.events.abort(retirementReason)),
14526
+ Promise.resolve().then(() => transportArg.operations.abort(retirementReason)),
14527
+ transportArg.openingTask,
14528
+ transportArg.receiveTask,
14529
+ transportArg.drainTask,
14530
+ transportArg.deliveryTail,
14531
+ ...[...transportArg.activeAcknowledgements.values()].map((entryArg) => entryArg.operation),
14532
+ ].filter((candidateArg): candidateArg is Promise<void> => candidateArg !== undefined))
14533
+ .then(() => {
14534
+ transportArg.retirementSettled = true;
14535
+ transportArg.lifecycle = 'retired';
14536
+ const stillAssociated = viewArg.transport === transportArg;
14537
+ const slot = this.browserViewSlots.get(viewArg.peerId);
14538
+ if (stillAssociated && viewArg.closing) viewArg.transport = undefined;
14539
+ if (
14540
+ transportArg.retirementDeadlineExpired
14541
+ && stillAssociated
14542
+ && viewArg.closing
14543
+ && slot?.view === viewArg
14544
+ ) {
14545
+ void this.requestBrowserViewCleanup(slot, viewArg, retirementReason).catch((errorArg) => {
14546
+ console.error(`Browser view ${viewArg.id} late cleanup failed.`, errorArg);
14547
+ });
13510
14548
  }
13511
- : {
13512
- type: 'state',
13513
- state: this.toControllerBrowserViewState(eventArg.state),
13514
- };
13515
- await typedSocket.createTypedRequest<IReq_ControllerBrowserViewEvent>(
13516
- 'controller.browser.view.event',
13517
- viewArg.transportConnection,
13518
- { timeoutMs: 5_000 },
13519
- ).fire({
13520
- viewId: viewArg.id,
13521
- resourceId: viewArg.resource.id,
13522
- attachmentRevision: viewArg.resource.attachmentRevision,
13523
- event,
13524
- }, { timeoutMs: 5_000, maxRetries: 0 });
13525
- return;
14549
+ });
14550
+ transportArg.retirementSettlement = settlement;
13526
14551
  }
13527
- const { data, ...frame } = eventArg.frame;
13528
- const integrity: plugins.typedrequestInterfaces.IVirtualStreamIntegrity = Object.freeze({
13529
- algorithm: 'sha256',
13530
- byteLength: data.byteLength,
13531
- digest: `sha256:${plugins.crypto.createHash('sha256').update(data).digest('hex')}`,
13532
- });
13533
- const authorization: IControllerBrowserFrameStreamAuthorization = Object.freeze({
13534
- marker: browserFrameStreamAuthorizationMarker,
13535
- view: viewArg,
13536
- authority: viewArg.authority,
13537
- transportPeer: viewArg.transportPeer,
13538
- contentType: frame.mimeType,
13539
- integrity,
14552
+ if (transportArg.retirementSettled) return Promise.resolve();
14553
+ const retirement = this.waitForBrowserTransportSettlement(
14554
+ transportArg.retirementSettlement,
14555
+ deadlineAtArg,
14556
+ ).catch((errorArg) => {
14557
+ transportArg.retirementDeadlineExpired = true;
14558
+ throw errorArg;
13540
14559
  });
13541
- const stream = typedSocket.createVirtualStream({
13542
- target: viewArg.transportConnection,
13543
- creatorDirection: 'send',
13544
- contentType: frame.mimeType,
13545
- integrity,
13546
- authorization,
14560
+ let tracked!: Promise<void>;
14561
+ tracked = retirement.finally(() => {
14562
+ if (transportArg.retirementTask === tracked) transportArg.retirementTask = undefined;
13547
14563
  });
13548
- viewArg.activeFrameStreams.add(stream);
13549
- const event: IReq_ControllerBrowserViewEvent['request']['event'] = {
13550
- type: 'frame',
13551
- frame: { ...frame, dataStream: stream },
13552
- };
13553
- const push = typedSocket.createTypedRequest<IReq_ControllerBrowserViewEvent>(
13554
- 'controller.browser.view.event',
13555
- viewArg.transportConnection,
13556
- { timeoutMs: 5_000 },
13557
- ).fire({
13558
- viewId: viewArg.id,
13559
- resourceId: viewArg.resource.id,
13560
- attachmentRevision: viewArg.resource.attachmentRevision,
13561
- event,
13562
- }, { timeoutMs: 5_000, maxRetries: 0 });
13563
- const production = (async () => {
13564
- await stream.send(data);
13565
- await stream.close();
13566
- })();
14564
+ transportArg.retirementTask = tracked;
14565
+ return tracked;
14566
+ }
14567
+
14568
+ private async waitForBrowserTransportSettlement(
14569
+ settlementArg: Promise<void>,
14570
+ deadlineAtArg: number,
14571
+ ): Promise<void> {
14572
+ const remainingMs = deadlineAtArg - Date.now();
14573
+ if (remainingMs <= 0) throw new Error('Browser stream generation cleanup timed out.');
14574
+ let timeout: ReturnType<typeof setTimeout> | undefined;
13567
14575
  try {
13568
- await Promise.all([push, production]);
13569
- } catch (errorArg) {
13570
- await stream.abort(errorArg).catch(() => undefined);
13571
- await Promise.allSettled([push, production]);
13572
- throw errorArg;
14576
+ await Promise.race([
14577
+ settlementArg,
14578
+ new Promise<never>((_resolve, reject) => {
14579
+ timeout = setTimeout(
14580
+ () => reject(new Error('Browser stream generation cleanup timed out.')),
14581
+ remainingMs,
14582
+ );
14583
+ timeout.unref?.();
14584
+ }),
14585
+ ]);
13573
14586
  } finally {
13574
- viewArg.activeFrameStreams.delete(stream);
14587
+ if (timeout) clearTimeout(timeout);
13575
14588
  }
13576
14589
  }
13577
14590
 
13578
- private bindBrowserFrameStreamAuthorization(
14591
+ private browserOperationError(errorArg: unknown): { code: string; message: string } {
14592
+ const typedError = asTypedResponseError(errorArg);
14593
+ const code = typeof typedError.errorData?.code === 'string'
14594
+ ? typedError.errorData.code.slice(0, 128)
14595
+ : 'browser_operation_failed';
14596
+ return { code, message: typedError.message.slice(0, 512) };
14597
+ }
14598
+
14599
+ private bindBrowserStreamAuthorization(
13579
14600
  authorizationArg: unknown,
13580
14601
  contextArg: Readonly<plugins.typedsocket.IVirtualStreamAuthorizationContext>,
13581
14602
  ): plugins.typedsocket.IVirtualStreamAuthorizationBinding {
13582
14603
  if (typeof authorizationArg !== 'object' || authorizationArg === null) {
13583
- throw new Error('Unknown browser frame stream authority.');
14604
+ throw new Error('Unknown browser stream authority.');
13584
14605
  }
13585
- const authorization = authorizationArg as Partial<IControllerBrowserFrameStreamAuthorization>;
14606
+ const authorization = authorizationArg as Partial<IControllerBrowserStreamAuthorization>;
13586
14607
  const view = authorization.view;
13587
14608
  if (
13588
- authorization.marker !== browserFrameStreamAuthorizationMarker
14609
+ authorization.marker !== browserStreamAuthorizationMarker
13589
14610
  || !view
13590
14611
  || authorization.authority !== view.authority
13591
14612
  || authorization.transportPeer !== view.transportPeer
14613
+ || typeof authorization.transportIdentity !== 'symbol'
13592
14614
  || contextArg.target !== view.transportPeer
13593
- || contextArg.creatorDirection !== 'send'
14615
+ || authorization.streamGeneration !== view.streamGeneration
14616
+ || contextArg.creatorDirection !== authorization.creatorDirection
13594
14617
  || contextArg.contentType !== authorization.contentType
13595
- || contextArg.integrity?.algorithm !== authorization.integrity?.algorithm
13596
- || contextArg.integrity?.byteLength !== authorization.integrity?.byteLength
13597
- || contextArg.integrity?.digest !== authorization.integrity?.digest
14618
+ || contextArg.integrity !== undefined
13598
14619
  || view.closing
13599
- || view.activationState === 'inactive'
13600
14620
  || this.browserViews.get(view.id) !== view
13601
14621
  || this.browserViewSlots.get(view.peerId)?.view !== view
13602
- || !this.isBrowserRuntimeAuthorityCurrent(view)
13603
- ) throw new Error('The browser frame stream authority is unavailable.');
13604
- const bound = authorization as IControllerBrowserFrameStreamAuthorization;
14622
+ || !this.isBrowserStreamBindingAuthorityCurrent(
14623
+ view,
14624
+ authorization.streamGeneration,
14625
+ authorization.transportIdentity,
14626
+ )
14627
+ ) throw new Error('The browser stream authority is unavailable.');
14628
+ const bound = authorization as IControllerBrowserStreamAuthorization;
13605
14629
  return {
13606
- revalidate: (revalidationArg) => this.revalidateBrowserFrameStream(
14630
+ revalidate: (revalidationArg) => this.revalidateBrowserStream(
13607
14631
  bound,
13608
14632
  revalidationArg,
13609
14633
  ),
13610
14634
  };
13611
14635
  }
13612
14636
 
13613
- private async revalidateBrowserFrameStream(
13614
- authorizationArg: IControllerBrowserFrameStreamAuthorization,
14637
+ private async revalidateBrowserStream(
14638
+ authorizationArg: IControllerBrowserStreamAuthorization,
13615
14639
  contextArg: Readonly<plugins.typedsocket.IVirtualStreamRevalidationContext>,
13616
14640
  ): Promise<boolean> {
13617
14641
  const view = authorizationArg.view;
@@ -13621,32 +14645,93 @@ export class OpenCodeController {
13621
14645
  || contextArg.connection.peer !== authorizationArg.transportPeer
13622
14646
  || view.transportPeer !== authorizationArg.transportPeer
13623
14647
  || view.authority !== authorizationArg.authority
13624
- || view.closing
13625
- || view.activationState === 'inactive'
13626
- || this.browserViews.get(view.id) !== view
13627
- || this.browserViewSlots.get(view.peerId)?.view !== view
13628
- || !this.authenticatedPeerIds.has(view.peerId)
13629
- || typeof view.transportPeer.context.state[authenticatedPeerStateKey] !== 'string'
14648
+ || view.streamGeneration !== authorizationArg.streamGeneration
14649
+ || !this.isBrowserViewInMemoryAuthorityCurrent(
14650
+ view,
14651
+ authorizationArg.streamGeneration,
14652
+ authorizationArg.transportIdentity,
14653
+ )
13630
14654
  || view.transportPeer.context.headers.get('origin') !== this.requireRuntimeConfig().publicOrigin
13631
- || !this.isBrowserRuntimeAuthorityCurrent(view)
13632
14655
  ) return false;
13633
14656
  try {
13634
- const current = await awaitWithAbortSignal(this.requireAuthStore().getResource(
13635
- view.resource.projectId,
13636
- view.resource.id,
13637
- ), contextArg.abortSignal);
13638
- return current !== undefined
13639
- && current.kind === 'browser'
13640
- && current.lifecycle === 'active'
13641
- && current.pendingAttachment === undefined
13642
- && current.attachmentAuthorityId === view.resource.attachmentAuthorityId
13643
- && current.attachmentRevision === view.resource.attachmentRevision
13644
- && this.isBrowserRuntimeAuthorityCurrent(view);
14657
+ await this.assertBrowserViewAuthority(
14658
+ view,
14659
+ authorizationArg.streamGeneration,
14660
+ authorizationArg.transportIdentity,
14661
+ contextArg.abortSignal,
14662
+ );
14663
+ return true;
13645
14664
  } catch {
13646
14665
  return false;
13647
14666
  }
13648
14667
  }
13649
14668
 
14669
+ private isBrowserStreamBindingAuthorityCurrent(
14670
+ viewArg: IControllerBrowserView,
14671
+ streamGenerationArg: number,
14672
+ transportIdentityArg: symbol,
14673
+ ): boolean {
14674
+ return !viewArg.closing
14675
+ && viewArg.lifecycle !== 'closing'
14676
+ && viewArg.streamGeneration === streamGenerationArg
14677
+ && viewArg.pendingTransportIdentity === transportIdentityArg
14678
+ && this.browserViews.get(viewArg.id) === viewArg
14679
+ && this.browserViewSlots.get(viewArg.peerId)?.view === viewArg
14680
+ && this.authenticatedPeerIds.has(viewArg.peerId)
14681
+ && viewArg.transportPeer.context.state[authenticatedPeerStateKey] === viewArg.credentialId
14682
+ && this.isBrowserRuntimeAuthorityCurrent(viewArg);
14683
+ }
14684
+
14685
+ private isBrowserViewInMemoryAuthorityCurrent(
14686
+ viewArg: IControllerBrowserView,
14687
+ streamGenerationArg: number,
14688
+ transportIdentityArg: symbol,
14689
+ ): boolean {
14690
+ return !viewArg.closing
14691
+ && viewArg.lifecycle !== 'closing'
14692
+ && viewArg.streamGeneration === streamGenerationArg
14693
+ && viewArg.transport?.identity === transportIdentityArg
14694
+ && this.browserViews.get(viewArg.id) === viewArg
14695
+ && this.browserViewSlots.get(viewArg.peerId)?.view === viewArg
14696
+ && this.authenticatedPeerIds.has(viewArg.peerId)
14697
+ && viewArg.transportPeer.context.state[authenticatedPeerStateKey] === viewArg.credentialId
14698
+ && this.isBrowserRuntimeAuthorityCurrent(viewArg);
14699
+ }
14700
+
14701
+ private async assertBrowserViewAuthority(
14702
+ viewArg: IControllerBrowserView,
14703
+ streamGenerationArg: number,
14704
+ transportIdentityArg: symbol,
14705
+ signalArg: AbortSignal,
14706
+ ): Promise<void> {
14707
+ signalArg.throwIfAborted();
14708
+ if (!this.isBrowserViewInMemoryAuthorityCurrent(
14709
+ viewArg,
14710
+ streamGenerationArg,
14711
+ transportIdentityArg,
14712
+ )) {
14713
+ throw new ControllerResourceUnavailableError('The browser view authority is unavailable.');
14714
+ }
14715
+ const current = await awaitWithAbortSignal(this.requireAuthStore().getResource(
14716
+ viewArg.resource.projectId,
14717
+ viewArg.resource.id,
14718
+ ), signalArg);
14719
+ signalArg.throwIfAborted();
14720
+ if (
14721
+ !current
14722
+ || current.kind !== 'browser'
14723
+ || current.lifecycle !== 'active'
14724
+ || current.pendingAttachment
14725
+ || current.attachmentAuthorityId !== viewArg.resource.attachmentAuthorityId
14726
+ || current.attachmentRevision !== viewArg.resource.attachmentRevision
14727
+ || !this.isBrowserViewInMemoryAuthorityCurrent(
14728
+ viewArg,
14729
+ streamGenerationArg,
14730
+ transportIdentityArg,
14731
+ )
14732
+ ) throw new ControllerResourceUnavailableError('The browser resource changed.');
14733
+ }
14734
+
13650
14735
  private isBrowserRuntimeAuthorityCurrent(viewArg: IControllerBrowserView): boolean {
13651
14736
  try {
13652
14737
  return viewArg.handle.lease.isAuthorityCurrent(viewArg.authority);
@@ -13655,11 +14740,14 @@ export class OpenCodeController {
13655
14740
  }
13656
14741
  }
13657
14742
 
13658
- private toControllerBrowserViewState(
14743
+ private nextControllerBrowserViewState(
14744
+ viewArg: IControllerBrowserView,
13659
14745
  stateArg: plugins.browserRuntime.IBrowserRuntimeState,
13660
- ): IReq_ControllerBrowserViewOpen['response']['state'] {
14746
+ ): IControllerBrowserViewState {
13661
14747
  const { lastError, ...state } = stateArg;
14748
+ viewArg.stateRevision += 1;
13662
14749
  return {
14750
+ revision: viewArg.stateRevision,
13663
14751
  ...state,
13664
14752
  ...(lastError === undefined ? {} : {
13665
14753
  lastError: { ...lastError, message: lastError.code },