@modelprofile.com/browser-runtime 4.3.0 → 5.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.
@@ -28,6 +28,7 @@ import type {
28
28
  IBrowserRuntimeOptions,
29
29
  IBrowserResourceRegistration,
30
30
  IBrowserRuntimeState,
31
+ IBrowserRuntimeViewportResult,
31
32
  IBrowserScreenshotResult,
32
33
  ICreateBrowserResourceRequest,
33
34
  TQualifiedBrowserSessionId,
@@ -116,6 +117,7 @@ type TCapabilityRecord = TBrowserCapabilityDescriptor & {
116
117
  expiryTimer: ReturnType<typeof setTimeout>;
117
118
  disconnect?: (signal: AbortSignal) => Promise<void> | void;
118
119
  lease?: ILeaseRecord;
120
+ acquiring?: boolean;
119
121
  revocationPromise?: Promise<void>;
120
122
  revocationInvokeDisconnect?: boolean;
121
123
  revocationComplete?: boolean;
@@ -135,8 +137,15 @@ interface ILeaseRecord {
135
137
  authorityGeneration: number;
136
138
  released: boolean;
137
139
  releasePromise?: Promise<void>;
138
- releaseGeneration?: number;
139
- releaseOwnsFence?: boolean;
140
+ preferredViewport?: plugins.smartpuppeteer.ILiveBrowserViewport;
141
+ heldKeys: Map<string, plugins.smartpuppeteer.ILiveBrowserKeyInput>;
142
+ heldButtons: Map<string, plugins.smartpuppeteer.ILiveBrowserMouseInput>;
143
+ }
144
+
145
+ export interface IBrowserRuntimeLeaseRenewalOptions {
146
+ signal?: AbortSignal;
147
+ /** Lifetime from successful reauthorization; defaults to the runtime capability lifetime. */
148
+ expiresInMs?: number;
140
149
  }
141
150
 
142
151
  interface IOperationRecord {
@@ -165,6 +174,8 @@ interface IQueuedOperationRecord {
165
174
  reject(error: unknown): void;
166
175
  state: 'queued' | 'starting' | 'active' | 'settled';
167
176
  onQueuedAbort?: () => void;
177
+ /** Resource-owned cleanup, admitted only for this exact released participant. */
178
+ participantCleanup?: boolean;
168
179
  }
169
180
 
170
181
  interface IOutstandingFrameRecord {
@@ -172,6 +183,7 @@ interface IOutstandingFrameRecord {
172
183
  timer?: ReturnType<typeof setTimeout>;
173
184
  session: ILiveBrowserSessionLike;
174
185
  incarnationGeneration: number;
186
+ producerAcknowledgement: Promise<TFrameAcknowledgementOutcome>;
175
187
  }
176
188
 
177
189
  type TFrameAcknowledgementOutcome =
@@ -202,6 +214,7 @@ interface IFrameRefreshRecord {
202
214
  abandoned: boolean;
203
215
  boundaryCandidate?: plugins.smartpuppeteer.ILiveBrowserFrameIdentity;
204
216
  failure?: BrowserRuntimeError;
217
+ producerFailure?: Promise<boolean>;
205
218
  }
206
219
 
207
220
  interface IResourceSlot {
@@ -222,12 +235,15 @@ interface IResourceSlot {
222
235
  proxy?: BrowserEgressProxy;
223
236
  profileDirectory?: string;
224
237
  unsubscribeSession?: () => void;
225
- lease?: ILeaseRecord;
238
+ leases: Map<string, ILeaseRecord>;
226
239
  operation?: IOperationRecord;
227
240
  operationQueue: IQueuedOperationRecord[];
228
241
  operationSchedulerRunning: boolean;
229
- frameSubscription?: IFrameSubscriptionRecord;
242
+ frameSubscriptions: Map<string, IFrameSubscriptionRecord>;
243
+ highestFrameSequence: number;
244
+ producerAcknowledgements: Set<Promise<TFrameAcknowledgementOutcome>>;
230
245
  frameRefresh?: IFrameRefreshRecord;
246
+ frameFailure?: { session: ILiveBrowserSessionLike; promise: Promise<boolean> };
231
247
  idleTimer?: ReturnType<typeof setTimeout>;
232
248
  lifecycleTail: Promise<void>;
233
249
  lifecycleOperationCount: number;
@@ -259,6 +275,8 @@ export class BrowserRuntime {
259
275
  private readonly failedRevocations = new Set<TCapabilityRecord>();
260
276
  private readonly capabilityMutex = new TransitionMutex();
261
277
  private readonly cleanupOperations = new Set<Promise<void>>();
278
+ // Retain admission until the host callback actually settles, including after cancellation.
279
+ private readonly leaseRenewals = new Set<ILeaseRecord>();
262
280
  private readonly framedPeers = new Set<BrowserRuntimeFramedServerPeer>();
263
281
  private startPromise?: Promise<void>;
264
282
  private stopPromise?: Promise<void>;
@@ -689,6 +707,10 @@ export class BrowserRuntime {
689
707
  permanentlyFenced: false,
690
708
  retirementPending: false,
691
709
  attachmentFenceCount: 0,
710
+ leases: new Map(),
711
+ frameSubscriptions: new Map(),
712
+ highestFrameSequence: 0,
713
+ producerAcknowledgements: new Set(),
692
714
  operationQueue: [],
693
715
  operationSchedulerRunning: false,
694
716
  lifecycleTail: Promise.resolve(),
@@ -831,7 +853,6 @@ export class BrowserRuntime {
831
853
  const sessionId = requestArg.sessionId === undefined
832
854
  ? undefined
833
855
  : this.validateQualifiedSessionId(requestArg.sessionId);
834
- if (sessionId?.harnessId === 'codex') throw new BrowserRuntimeError('INVALID_INPUT');
835
856
  if (
836
857
  (role === 'agent' && !sessionId)
837
858
  || (role === 'human' && sessionId !== undefined)
@@ -839,7 +860,7 @@ export class BrowserRuntime {
839
860
  || sessionId?.harnessId !== 'flex'))
840
861
  || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
841
862
  || runId !== undefined
842
- || sessionId?.harnessId !== 'opencode'))
863
+ || sessionId === undefined))
843
864
  || (source === 'human' && (scopeId !== undefined || channelId !== undefined
844
865
  || runId !== undefined))
845
866
  ) throw new BrowserRuntimeError('INVALID_INPUT');
@@ -1016,9 +1037,7 @@ export class BrowserRuntime {
1016
1037
  );
1017
1038
  const slot = this.getRegisteredSlot(capability.projectId, capability.browserResourceId);
1018
1039
  this.assertCapabilityStillAttached(capability, slot);
1019
- return capability.role === 'agent'
1020
- ? this.acquireAgentLease(slot, capability, digest, optionsArg)
1021
- : this.acquireHumanLease(slot, capability, digest, optionsArg);
1040
+ return this.acquireParticipantLease(slot, capability, digest, optionsArg);
1022
1041
  }
1023
1042
 
1024
1043
  public authorizeCapability(
@@ -1130,6 +1149,75 @@ export class BrowserRuntime {
1130
1149
  return this.createLeaseAuthority(lease);
1131
1150
  }
1132
1151
 
1152
+ /** @internal */
1153
+ public async renewLease(
1154
+ lease: ILeaseRecord,
1155
+ optionsArg: IBrowserRuntimeLeaseRenewalOptions = {},
1156
+ ): Promise<number> {
1157
+ const options = validateExactKeys(optionsArg, ['signal', 'expiresInMs'], 'renewal');
1158
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
1159
+ throw new BrowserRuntimeError('INVALID_INPUT');
1160
+ }
1161
+ const lifetimeMs = validateOptionalInteger(
1162
+ options.expiresInMs, 'expiresInMs', 1000,
1163
+ this.options.capabilityMaximumTtlMs, this.options.capabilityDefaultTtlMs,
1164
+ );
1165
+ const authority = this.getLeaseAuthority(lease);
1166
+ if (!this.isLeaseAuthorityCurrent(lease, authority)) throw new BrowserRuntimeError('FENCED');
1167
+ if (this.leaseRenewals.has(lease)) throw new BrowserRuntimeError('BUSY');
1168
+ if (this.leaseRenewals.size >= this.options.maxCapabilities) {
1169
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1170
+ }
1171
+ const controller = new AbortController();
1172
+ const signal = AbortSignal.any([
1173
+ controller.signal, this.lifecycleController.signal, lease.controller.signal,
1174
+ ...(optionsArg.signal ? [optionsArg.signal] : []),
1175
+ ]);
1176
+ signal.throwIfAborted();
1177
+ const timeout = setTimeout(() => controller.abort(new BrowserRuntimeError('TIMEOUT')),
1178
+ Math.min(this.options.authorizationTimeoutMs, lease.capability.expiresAt - Date.now()));
1179
+ timeout.unref();
1180
+ const binding = this.describeCapabilityIdentity(lease.capability);
1181
+ if (binding.sessionId) Object.freeze(binding.sessionId);
1182
+ Object.freeze(binding);
1183
+ this.leaseRenewals.add(lease);
1184
+ const authorization = Promise.resolve().then(() => {
1185
+ signal.throwIfAborted();
1186
+ return this.options.authorizeCapability(binding, signal);
1187
+ });
1188
+ void authorization.then(
1189
+ () => this.leaseRenewals.delete(lease),
1190
+ () => this.leaseRenewals.delete(lease),
1191
+ );
1192
+ let abortListener: (() => void) | undefined;
1193
+ try {
1194
+ const authorized = await new Promise<boolean>((resolve, reject) => {
1195
+ abortListener = () => reject(signal.reason ?? new BrowserRuntimeError('ABORTED'));
1196
+ signal.addEventListener('abort', abortListener, { once: true });
1197
+ if (signal.aborted) abortListener();
1198
+ void authorization.then(resolve, () => reject(new BrowserRuntimeError('AUTHORIZATION_DENIED')));
1199
+ });
1200
+ signal.throwIfAborted();
1201
+ if (authorized !== true) throw new BrowserRuntimeError('AUTHORIZATION_DENIED');
1202
+ // Neither expiry, release, attachment change nor incarnation replacement may be revived.
1203
+ if (!this.isLeaseAuthorityCurrent(lease, authority)) throw new BrowserRuntimeError('CAPABILITY_REVOKED');
1204
+ const capability = lease.capability;
1205
+ const expiresAt = Date.now() + lifetimeMs;
1206
+ clearTimeout(capability.expiryTimer);
1207
+ capability.expiresAt = expiresAt;
1208
+ capability.expiryTimer = setTimeout(() => {
1209
+ if (capability.state === 'active') capability.state = 'expired';
1210
+ this.trackCleanup(this.revokeCapabilityRecord(capability));
1211
+ }, lifetimeMs);
1212
+ capability.expiryTimer.unref();
1213
+ return expiresAt;
1214
+ } finally {
1215
+ clearTimeout(timeout);
1216
+ if (abortListener) signal.removeEventListener('abort', abortListener);
1217
+ controller.abort(new BrowserRuntimeError('ABORTED'));
1218
+ }
1219
+ }
1220
+
1133
1221
  /** @internal */
1134
1222
  public isLeaseAuthorityCurrent(
1135
1223
  lease: ILeaseRecord,
@@ -1248,17 +1336,61 @@ export class BrowserRuntime {
1248
1336
  lease: ILeaseRecord,
1249
1337
  viewport: plugins.smartpuppeteer.ILiveBrowserViewport,
1250
1338
  operationOptions: IBrowserRuntimeOperationOptions = {},
1251
- ): Promise<void> {
1339
+ ): Promise<IBrowserRuntimeViewportResult> {
1252
1340
  this.requireHuman(lease);
1253
- await this.runOperation(
1254
- lease,
1255
- 'setViewport',
1256
- 'viewport',
1257
- operationOptions,
1341
+ const preferredViewport = this.validatePreferredViewport(viewport);
1342
+ return this.runOperation(lease, 'setViewport', 'viewport', operationOptions,
1258
1343
  async (signal, session) => {
1344
+ const previous = lease.preferredViewport;
1345
+ lease.preferredViewport = preferredViewport;
1346
+ try {
1347
+ return await this.applySharedViewport(lease.slot, session, signal);
1348
+ } catch (error) {
1349
+ lease.preferredViewport = previous;
1350
+ throw error;
1351
+ }
1352
+ });
1353
+ }
1354
+
1355
+ private validatePreferredViewport(
1356
+ value: plugins.smartpuppeteer.ILiveBrowserViewport,
1357
+ ): plugins.smartpuppeteer.ILiveBrowserViewport {
1358
+ const record = validateExactKeys(value, ['width', 'height', 'deviceScaleFactor'], 'viewport');
1359
+ const width = validateInteger(record.width, 'viewport.width', 1, 4096);
1360
+ const height = validateInteger(record.height, 'viewport.height', 1, 4096);
1361
+ const deviceScaleFactor = record.deviceScaleFactor;
1362
+ if (typeof deviceScaleFactor !== 'number' || !Number.isFinite(deviceScaleFactor)
1363
+ || deviceScaleFactor < 0.25 || deviceScaleFactor > 3
1364
+ || Math.ceil(width * deviceScaleFactor) * Math.ceil(height * deviceScaleFactor)
1365
+ > maxScreencastPixelArea
1366
+ ) throw new BrowserRuntimeError('INVALID_INPUT');
1367
+ return { width, height, deviceScaleFactor };
1368
+ }
1369
+
1370
+ private async applySharedViewport(
1371
+ slot: IResourceSlot,
1372
+ session: ILiveBrowserSessionLike,
1373
+ signal: AbortSignal,
1374
+ ): Promise<IBrowserRuntimeViewportResult> {
1375
+ const preferences = [...slot.leases.values()]
1376
+ .filter((lease) => lease.role === 'human' && !lease.released && lease.preferredViewport)
1377
+ .map((lease) => lease.preferredViewport!);
1378
+ const current = session.getState();
1379
+ const viewport = preferences.length ? {
1380
+ width: Math.min(...preferences.map((value) => value.width)),
1381
+ height: Math.min(...preferences.map((value) => value.height)),
1382
+ deviceScaleFactor: Math.min(...preferences.map((value) => value.deviceScaleFactor)),
1383
+ } : current.viewport;
1384
+ if (
1385
+ viewport.width !== current.viewport.width || viewport.height !== current.viewport.height
1386
+ || viewport.deviceScaleFactor !== current.viewport.deviceScaleFactor
1387
+ ) {
1388
+ await this.releaseAllParticipantInput(slot, session);
1389
+ signal.throwIfAborted();
1259
1390
  await session.setViewport(viewport, { signal });
1260
- },
1261
- );
1391
+ }
1392
+ const state = session.getState();
1393
+ return { viewport: { ...state.viewport }, viewportRevision: state.viewportRevision };
1262
1394
  }
1263
1395
 
1264
1396
  /** @internal */
@@ -1269,7 +1401,7 @@ export class BrowserRuntime {
1269
1401
  this.requireHuman(lease);
1270
1402
  this.requireValidLease(lease);
1271
1403
  const slot = lease.slot;
1272
- const subscription = slot.frameSubscription;
1404
+ const subscription = slot.frameSubscriptions.get(lease.leaseId);
1273
1405
  if (!subscription || subscription.closed || subscription.lease !== lease) {
1274
1406
  throw new BrowserRuntimeError('BUSY');
1275
1407
  }
@@ -1308,17 +1440,161 @@ export class BrowserRuntime {
1308
1440
  this.requireHuman(lease);
1309
1441
  await this.runOperation(lease, action, 'raw-input', operationOptions, async (_signal, session) => {
1310
1442
  if (action === 'dispatchMouse') {
1311
- await session.dispatchMouse(input as plugins.smartpuppeteer.ILiveBrowserMouseInput);
1443
+ const mouse = input as plugins.smartpuppeteer.ILiveBrowserMouseInput;
1444
+ if (mouse.type === 'down') {
1445
+ this.validateHeldInputTarget(session, mouse);
1446
+ if (!['none', 'left', 'middle', 'right', 'back', 'forward'].includes(mouse.button ?? 'left')
1447
+ || !Number.isFinite(mouse.x) || !Number.isFinite(mouse.y) || mouse.x < 0 || mouse.y < 0
1448
+ ) throw new BrowserRuntimeError('INVALID_INPUT');
1449
+ }
1450
+ const key = this.mouseOwnershipKey(mouse);
1451
+ if (mouse.type === 'up' && this.otherParticipantHolds(lease, 'heldButtons', key)) {
1452
+ lease.heldButtons.delete(key);
1453
+ return;
1454
+ }
1455
+ if (mouse.type === 'down' && lease.heldButtons.size >= 5 && !lease.heldButtons.has(key)) {
1456
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1457
+ }
1458
+ if (mouse.type === 'down') lease.heldButtons.set(key, { ...mouse });
1459
+ await session.dispatchMouse({ ...mouse,
1460
+ buttons: this.sharedMouseButtons(lease.slot, mouse.tabId,
1461
+ mouse.type === 'up' ? { lease, key } : undefined),
1462
+ modifiers: this.sharedModifiers(lease.slot, mouse.tabId, mouse.modifiers),
1463
+ });
1464
+ if (mouse.type === 'up') lease.heldButtons.delete(key);
1312
1465
  } else if (action === 'dispatchWheel') {
1313
- await session.dispatchWheel(input as plugins.smartpuppeteer.ILiveBrowserWheelInput);
1466
+ const wheel = input as plugins.smartpuppeteer.ILiveBrowserWheelInput;
1467
+ await session.dispatchWheel({ ...wheel,
1468
+ modifiers: this.sharedModifiers(lease.slot, wheel.tabId, wheel.modifiers),
1469
+ });
1314
1470
  } else if (action === 'dispatchKey') {
1315
- await session.dispatchKey(input as plugins.smartpuppeteer.ILiveBrowserKeyInput);
1471
+ const keyInput = input as plugins.smartpuppeteer.ILiveBrowserKeyInput;
1472
+ if (keyInput.type === 'down') {
1473
+ this.validateHeldInputTarget(session, keyInput);
1474
+ if (typeof keyInput.key !== 'string' || keyInput.key.length < 1 || keyInput.key.length > 64
1475
+ || (keyInput.code !== undefined && (typeof keyInput.code !== 'string'
1476
+ || keyInput.code.length < 1 || keyInput.code.length > 64))
1477
+ ) throw new BrowserRuntimeError('INVALID_INPUT');
1478
+ }
1479
+ const key = this.keyOwnershipKey(keyInput);
1480
+ if (keyInput.type === 'up' && this.otherParticipantHolds(lease, 'heldKeys', key)) {
1481
+ lease.heldKeys.delete(key);
1482
+ return;
1483
+ }
1484
+ if (keyInput.type === 'down' && lease.heldKeys.size >= 64 && !lease.heldKeys.has(key)) {
1485
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1486
+ }
1487
+ if (keyInput.type === 'down') lease.heldKeys.set(key, { ...keyInput });
1488
+ await session.dispatchKey({ ...keyInput,
1489
+ modifiers: this.sharedModifiers(lease.slot, keyInput.tabId, keyInput.modifiers,
1490
+ keyInput.type === 'up' ? { lease, key } : undefined),
1491
+ });
1492
+ if (keyInput.type === 'up') lease.heldKeys.delete(key);
1316
1493
  } else {
1317
1494
  await session.insertText(input as plugins.smartpuppeteer.ILiveBrowserInsertTextInput);
1318
1495
  }
1319
1496
  });
1320
1497
  }
1321
1498
 
1499
+ private keyOwnershipKey(input: plugins.smartpuppeteer.ILiveBrowserKeyInput): string {
1500
+ return JSON.stringify([input.tabId, input.code ?? input.key]);
1501
+ }
1502
+
1503
+ private validateHeldInputTarget(
1504
+ session: ILiveBrowserSessionLike,
1505
+ input: plugins.smartpuppeteer.ILiveBrowserInputBase,
1506
+ ): void {
1507
+ const state = session.getState();
1508
+ const tab = state.tabs.find((value) => value.id === state.activeTabId);
1509
+ if (!tab || tab.status !== 'open' || tab.id !== input.tabId
1510
+ || tab.generation !== input.generation || state.viewportRevision !== input.viewportRevision
1511
+ ) throw new BrowserRuntimeError('INVALID_INPUT');
1512
+ }
1513
+
1514
+ private mouseOwnershipKey(input: plugins.smartpuppeteer.ILiveBrowserMouseInput): string {
1515
+ return JSON.stringify([input.tabId, input.button ?? 'left']);
1516
+ }
1517
+
1518
+ private otherParticipantHolds(
1519
+ lease: ILeaseRecord, kind: 'heldKeys' | 'heldButtons', key: string,
1520
+ ): boolean {
1521
+ return [...lease.slot.leases.values()].some((other) => other !== lease && other[kind].has(key));
1522
+ }
1523
+
1524
+ private sharedMouseButtons(
1525
+ slot: IResourceSlot,
1526
+ tabId: string,
1527
+ excluding?: { lease: ILeaseRecord; key: string },
1528
+ ): number {
1529
+ const masks = { left: 1, right: 2, middle: 4, back: 8, forward: 16, none: 0 };
1530
+ let buttons = 0;
1531
+ for (const lease of slot.leases.values()) {
1532
+ for (const [key, input] of lease.heldButtons) {
1533
+ if (input.tabId !== tabId || (excluding?.lease === lease && excluding.key === key)) continue;
1534
+ buttons |= masks[input.button ?? 'left'];
1535
+ }
1536
+ }
1537
+ return buttons;
1538
+ }
1539
+
1540
+ private sharedModifiers(
1541
+ slot: IResourceSlot,
1542
+ tabId: string,
1543
+ supplied?: plugins.smartpuppeteer.ILiveBrowserModifierState,
1544
+ excluding?: { lease: ILeaseRecord; key: string },
1545
+ ): plugins.smartpuppeteer.ILiveBrowserModifierState {
1546
+ const modifiers = { ...supplied };
1547
+ for (const lease of slot.leases.values()) {
1548
+ for (const [key, input] of lease.heldKeys) {
1549
+ if (input.tabId !== tabId || (excluding?.lease === lease && excluding.key === key)) continue;
1550
+ if (input.key === 'Shift') modifiers.shift = true;
1551
+ else if (input.key === 'Control') modifiers.control = true;
1552
+ else if (input.key === 'Alt') modifiers.alt = true;
1553
+ else if (input.key === 'Meta') modifiers.meta = true;
1554
+ }
1555
+ }
1556
+ return modifiers;
1557
+ }
1558
+
1559
+ private async releaseAllParticipantInput(
1560
+ slot: IResourceSlot, session: ILiveBrowserSessionLike,
1561
+ ): Promise<void> {
1562
+ for (const lease of slot.leases.values()) await this.releaseParticipantInput(lease, session);
1563
+ }
1564
+
1565
+ private async releaseParticipantInput(
1566
+ lease: ILeaseRecord, session: ILiveBrowserSessionLike,
1567
+ ): Promise<void> {
1568
+ if (!lease.heldKeys.size && !lease.heldButtons.size) return;
1569
+ let state = session.getState();
1570
+ let tab = state.tabs.find((value) => value.id === state.activeTabId);
1571
+ if (tab?.status === 'open' && !tab.streaming) {
1572
+ await session.refreshScreencast();
1573
+ state = session.getState();
1574
+ tab = state.tabs.find((value) => value.id === state.activeTabId);
1575
+ }
1576
+ const target = tab?.status === 'open'
1577
+ ? { tabId: tab.id, generation: tab.generation, viewportRevision: state.viewportRevision }
1578
+ : undefined;
1579
+ for (const [key, input] of lease.heldKeys) {
1580
+ if (!this.otherParticipantHolds(lease, 'heldKeys', key) && target?.tabId === input.tabId) {
1581
+ await session.dispatchKey({ ...target, type: 'up', key: input.key, code: input.code,
1582
+ modifiers: this.sharedModifiers(lease.slot, input.tabId, undefined, { lease, key }) });
1583
+ }
1584
+ lease.heldKeys.delete(key);
1585
+ }
1586
+ for (const [key, input] of lease.heldButtons) {
1587
+ if (!this.otherParticipantHolds(lease, 'heldButtons', key) && target?.tabId === input.tabId) {
1588
+ await session.dispatchMouse({ ...target, type: 'up', button: input.button,
1589
+ buttons: this.sharedMouseButtons(lease.slot, input.tabId, { lease, key }),
1590
+ x: Math.min(input.x, state.viewport.width - 1),
1591
+ y: Math.min(input.y, state.viewport.height - 1),
1592
+ modifiers: this.sharedModifiers(lease.slot, input.tabId) });
1593
+ }
1594
+ lease.heldButtons.delete(key);
1595
+ }
1596
+ }
1597
+
1322
1598
  /** @internal */
1323
1599
  public async subscribeLeaseEvents(
1324
1600
  lease: ILeaseRecord,
@@ -1332,7 +1608,7 @@ export class BrowserRuntime {
1332
1608
  let subscription: IFrameSubscriptionRecord;
1333
1609
  try {
1334
1610
  this.requireValidLease(lease);
1335
- if (slot.frameSubscription || slot.frameRefresh) throw new BrowserRuntimeError('BUSY');
1611
+ if (slot.frameSubscriptions.has(lease.leaseId)) throw new BrowserRuntimeError('BUSY');
1336
1612
  subscription = {
1337
1613
  lease,
1338
1614
  listener,
@@ -1340,7 +1616,7 @@ export class BrowserRuntime {
1340
1616
  highestSequence: 0,
1341
1617
  closed: false,
1342
1618
  };
1343
- slot.frameSubscription = subscription;
1619
+ slot.frameSubscriptions.set(lease.leaseId, subscription);
1344
1620
  this.pushToSubscription(subscription, {
1345
1621
  type: 'state',
1346
1622
  state: this.resourceState(slot.session!.getState()),
@@ -1359,45 +1635,13 @@ export class BrowserRuntime {
1359
1635
  acknowledgementArg: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
1360
1636
  ): Promise<boolean> {
1361
1637
  this.requireHuman(lease);
1362
- const acknowledgement = this.validateFrameAcknowledgement(acknowledgementArg, true);
1363
- const key = this.frameAcknowledgementKey(acknowledgement);
1364
- const slot = lease.slot;
1365
- const subscription = slot.frameSubscription;
1366
1638
  this.requireValidLease(lease);
1367
- if (!subscription || subscription.lease !== lease) return false;
1368
- const outstanding = subscription.outstanding.get(key);
1369
- if (!outstanding) return false;
1370
- const { session, incarnationGeneration } = outstanding;
1371
- const refresh = this.matchingFrameRefresh(
1372
- slot,
1373
- subscription,
1374
- session,
1375
- incarnationGeneration,
1376
- );
1377
- if (refresh?.admissionClosed) {
1378
- subscription.outstanding.delete(key);
1379
- if (outstanding.timer) clearTimeout(outstanding.timer);
1380
- outstanding.timer = undefined;
1381
- return false;
1382
- }
1383
- subscription.outstanding.delete(key);
1384
- if (outstanding.timer) clearTimeout(outstanding.timer);
1385
- outstanding.timer = undefined;
1386
- const refreshOwned = Boolean(refresh && !refresh.abandoned);
1387
- const outcome = await this.beginFrameAcknowledgement(
1388
- session,
1389
- outstanding.acknowledgement,
1390
- refreshOwned ? refresh : undefined,
1639
+ const acknowledgement = this.validateFrameAcknowledgement(acknowledgementArg, true);
1640
+ const subscription = lease.slot.frameSubscriptions.get(lease.leaseId);
1641
+ if (!subscription) return false;
1642
+ return this.retireOutstandingFrame(
1643
+ subscription, this.frameAcknowledgementKey(acknowledgement),
1391
1644
  );
1392
- if (outcome.status === 'fulfilled') return outcome.accepted;
1393
- if (refreshOwned) return false;
1394
- if (refresh && slot.frameRefresh === refresh) {
1395
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
1396
- return false;
1397
- }
1398
- await this.options.beforeFrameFailureTermination?.();
1399
- await this.handleFrameFailure(slot, session, incarnationGeneration, subscription.lease);
1400
- return false;
1401
1645
  }
1402
1646
 
1403
1647
  /** @internal */
@@ -1581,19 +1825,21 @@ export class BrowserRuntime {
1581
1825
  }
1582
1826
  }
1583
1827
 
1584
- private async acquireAgentLease(
1828
+ private async acquireParticipantLease(
1585
1829
  slot: IResourceSlot,
1586
1830
  capability: TCapabilityRecord,
1587
1831
  digest: plugins.Buffer,
1588
1832
  acquisition: TAcquireBrowserRuntimeLeaseOptions,
1589
1833
  ): Promise<BrowserRuntimeLease> {
1590
- const release = slot.mutex.tryAcquire();
1591
- if (!release) throw new BrowserRuntimeError('BUSY');
1834
+ this.assertSlotAvailable(slot);
1835
+ if (capability.lease || capability.acquiring) throw new BrowserRuntimeError('BUSY');
1836
+ capability.acquiring = true;
1837
+ let release: (() => void) | undefined;
1592
1838
  try {
1839
+ release = await slot.mutex.acquire();
1593
1840
  this.assertSlotAvailable(slot);
1594
1841
  this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1595
1842
  this.assertCapabilityStillAttached(capability, slot);
1596
- if (slot.lease || slot.operation) throw new BrowserRuntimeError('BUSY');
1597
1843
  await this.ensureSession(slot, acquisition.signal);
1598
1844
  await this.options.beforeLeasePublication?.();
1599
1845
  this.throwIfAcquisitionAborted(acquisition.signal);
@@ -1603,84 +1849,11 @@ export class BrowserRuntime {
1603
1849
  const lease = this.createLeaseRecord(slot, capability, digest);
1604
1850
  return new BrowserRuntimeLease(this, lease);
1605
1851
  } catch (error) {
1606
- if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1852
+ if (slot.session && slot.leases.size === 0) this.scheduleIdleTermination(slot);
1607
1853
  throw error;
1608
1854
  } finally {
1609
- release();
1610
- }
1611
- }
1612
-
1613
- private async acquireHumanLease(
1614
- slot: IResourceSlot,
1615
- capability: TCapabilityRecord,
1616
- digest: plugins.Buffer,
1617
- acquisition: TAcquireBrowserRuntimeLeaseOptions,
1618
- ): Promise<BrowserRuntimeLease> {
1619
- const release = await slot.mutex.acquire();
1620
- let generation = 0;
1621
- let preemptedLease: ILeaseRecord | undefined;
1622
- try {
1623
- this.assertSlotAvailable(slot);
1624
- this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1625
- this.assertCapabilityStillAttached(capability, slot);
1626
- this.throwIfAcquisitionAborted(acquisition.signal);
1627
- if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
1628
- if (slot.lease?.role === 'human') throw new BrowserRuntimeError('BUSY');
1629
- generation = ++slot.arbitrationGeneration;
1630
- slot.fencingGeneration = generation;
1631
- preemptedLease = slot.lease;
1632
- if (preemptedLease) {
1633
- preemptedLease.released = true;
1634
- preemptedLease.controller.abort(new BrowserRuntimeError('ABORTED'));
1635
- slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
1636
- this.notifyFramedLeaseEnded(preemptedLease);
1637
- }
1638
- } finally {
1639
- release();
1640
- }
1641
-
1642
- try {
1643
- if (preemptedLease) await this.quiesceSlot(slot);
1644
- this.throwIfAcquisitionAborted(acquisition.signal);
1645
- const finalRelease = await slot.mutex.acquire();
1646
- try {
1647
- if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
1648
- throw new BrowserRuntimeError('BUSY');
1649
- }
1650
- if (preemptedLease) {
1651
- if (preemptedLease.capability.lease === preemptedLease) {
1652
- preemptedLease.capability.lease = undefined;
1653
- }
1654
- if (slot.lease === preemptedLease) slot.lease = undefined;
1655
- }
1656
- if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
1657
- slot.fencingGeneration = undefined;
1658
- await this.ensureSession(slot, acquisition.signal);
1659
- await this.options.beforeLeasePublication?.();
1660
- this.throwIfAcquisitionAborted(acquisition.signal);
1661
- this.assertSlotAvailable(slot);
1662
- this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1663
- this.assertCapabilityStillAttached(capability, slot);
1664
- const lease = this.createLeaseRecord(slot, capability, digest);
1665
- return new BrowserRuntimeLease(this, lease);
1666
- } finally {
1667
- finalRelease();
1668
- }
1669
- } catch (error) {
1670
- const cleanupRelease = await slot.mutex.acquire();
1671
- try {
1672
- if (preemptedLease) {
1673
- if (preemptedLease.capability.lease === preemptedLease) {
1674
- preemptedLease.capability.lease = undefined;
1675
- }
1676
- if (slot.lease === preemptedLease) slot.lease = undefined;
1677
- }
1678
- if (slot.fencingGeneration === generation) slot.fencingGeneration = undefined;
1679
- if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1680
- } finally {
1681
- cleanupRelease();
1682
- }
1683
- throw error;
1855
+ capability.acquiring = false;
1856
+ release?.();
1684
1857
  }
1685
1858
  }
1686
1859
 
@@ -1691,7 +1864,7 @@ export class BrowserRuntime {
1691
1864
  ): ILeaseRecord {
1692
1865
  this.assertSlotAvailable(slot);
1693
1866
  this.assertCapabilityStillAttached(capability, slot);
1694
- if (capability.lease || slot.lease) throw new BrowserRuntimeError('BUSY');
1867
+ if (capability.lease) throw new BrowserRuntimeError('BUSY');
1695
1868
  if (slot.idleTimer) {
1696
1869
  clearTimeout(slot.idleTimer);
1697
1870
  slot.idleTimer = undefined;
@@ -1711,8 +1884,10 @@ export class BrowserRuntime {
1711
1884
  controller: new AbortController(),
1712
1885
  authorityGeneration: slot.authorityGeneration,
1713
1886
  released: false,
1887
+ heldKeys: new Map(),
1888
+ heldButtons: new Map(),
1714
1889
  };
1715
- slot.lease = lease;
1890
+ slot.leases.set(lease.leaseId, lease);
1716
1891
  capability.lease = lease;
1717
1892
  return lease;
1718
1893
  }
@@ -1877,6 +2052,7 @@ export class BrowserRuntime {
1877
2052
  operationOptions: IBrowserRuntimeOperationOptions,
1878
2053
  execute: (signal: AbortSignal, session: ILiveBrowserSessionLike) => Promise<T>,
1879
2054
  finalize?: () => Promise<void>,
2055
+ participantCleanup = false,
1880
2056
  ): Promise<T> {
1881
2057
  if (!operationOptions || typeof operationOptions !== 'object') {
1882
2058
  throw new BrowserRuntimeError('INVALID_INPUT');
@@ -1891,16 +2067,21 @@ export class BrowserRuntime {
1891
2067
  ) throw new BrowserRuntimeError('INVALID_INPUT');
1892
2068
  if (operationOptions.signal?.aborted) throw new BrowserRuntimeError('ABORTED');
1893
2069
  const slot = lease.slot;
1894
- this.requireValidLease(lease);
2070
+ this.requireQueuedLease(lease, participantCleanup);
1895
2071
  this.assertSlotAvailable(slot);
1896
- if (slot.operationQueue.length >= this.options.maxQueuedOperationsPerLease) {
2072
+ if (!participantCleanup && (
2073
+ slot.operationQueue.filter((entry) => entry.lease === lease).length
2074
+ >= this.options.maxQueuedOperationsPerLease
2075
+ || slot.operationQueue.length >= this.options.maxQueuedOperationsPerLease
2076
+ * this.options.maxCapabilitiesPerResource
2077
+ )) {
1897
2078
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1898
2079
  }
1899
2080
  const timeoutMs = operationOptions.timeoutMs === undefined
1900
2081
  ? this.options.operationTimeoutMs
1901
2082
  : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
1902
2083
  const signal = AbortSignal.any([
1903
- lease.controller.signal,
2084
+ ...(participantCleanup ? [] : [lease.controller.signal]),
1904
2085
  this.lifecycleController.signal,
1905
2086
  operationOptions.signal ?? new AbortController().signal,
1906
2087
  ]);
@@ -1919,6 +2100,7 @@ export class BrowserRuntime {
1919
2100
  resolve: (value) => resolve(value as T),
1920
2101
  reject,
1921
2102
  state: 'queued',
2103
+ participantCleanup,
1922
2104
  };
1923
2105
  const onQueuedAbort = (): void => {
1924
2106
  if (queued.state !== 'queued' && queued.state !== 'starting') return;
@@ -1928,7 +2110,15 @@ export class BrowserRuntime {
1928
2110
  }
1929
2111
  queued.state = 'settled';
1930
2112
  signal.removeEventListener('abort', onQueuedAbort);
1931
- reject(this.queuedOperationAbortError(queued));
2113
+ const error = this.queuedOperationAbortError(queued);
2114
+ const frameFailure = slot.frameFailure;
2115
+ if (frameFailure && frameFailure.session === slot.session) {
2116
+ // Stop admission immediately, but expose the terminal result only
2117
+ // after cleanup of the failed producer's incarnation has settled.
2118
+ void frameFailure.promise.then(() => reject(error), reject);
2119
+ } else {
2120
+ reject(error);
2121
+ }
1932
2122
  };
1933
2123
  queued.onQueuedAbort = onQueuedAbort;
1934
2124
  signal.addEventListener('abort', onQueuedAbort, { once: true });
@@ -1992,7 +2182,7 @@ export class BrowserRuntime {
1992
2182
  try {
1993
2183
  if (queued.state === 'settled') return undefined;
1994
2184
  queued.signal.throwIfAborted();
1995
- this.requireValidLease(lease);
2185
+ this.requireQueuedLease(lease, queued.participantCleanup);
1996
2186
  this.assertSlotAvailable(slot);
1997
2187
  if (slot.operation) throw new BrowserRuntimeError('BUSY');
1998
2188
  session = slot.session!;
@@ -2053,13 +2243,12 @@ export class BrowserRuntime {
2053
2243
  let executionStarted = false;
2054
2244
  let executionSettled = false;
2055
2245
  try {
2056
- await this.runBeforeOperation(operationIdentity, combinedSignal);
2246
+ if (!queued.participantCleanup) await this.runBeforeOperation(operationIdentity, combinedSignal);
2057
2247
  const executionRelease = await slot.mutex.acquire();
2058
2248
  try {
2059
2249
  combinedSignal.throwIfAborted();
2060
- this.requireValidLease(lease);
2250
+ this.requireQueuedLease(lease, queued.participantCleanup);
2061
2251
  this.assertSlotAvailable(slot);
2062
- this.assertCapabilityStillAttached(lease.capability, slot);
2063
2252
  if (
2064
2253
  slot.operation !== operation
2065
2254
  || slot.session !== session
@@ -2067,7 +2256,13 @@ export class BrowserRuntime {
2067
2256
  || slot.incarnationGeneration !== incarnationGeneration
2068
2257
  ) throw new BrowserRuntimeError('ABORTED');
2069
2258
  executionStarted = true;
2070
- executionPromise = queued.execute(combinedSignal, session);
2259
+ executionPromise = (async () => {
2260
+ if (classification === 'navigation' || classification === 'tab') {
2261
+ await this.releaseAllParticipantInput(slot, session);
2262
+ }
2263
+ combinedSignal.throwIfAborted();
2264
+ return queued.execute(combinedSignal, session);
2265
+ })();
2071
2266
  operation.promise = executionPromise.then(() => undefined, () => undefined).finally(() => {
2072
2267
  executionSettled = true;
2073
2268
  });
@@ -2085,8 +2280,7 @@ export class BrowserRuntime {
2085
2280
  combinedSignal.removeEventListener('abort', onAbort);
2086
2281
  }
2087
2282
  if (combinedSignal.aborted) throw combinedSignal.reason;
2088
- this.requireValidLease(lease);
2089
- this.assertCapabilityStillAttached(lease.capability, slot);
2283
+ this.requireQueuedLease(lease, queued.participantCleanup);
2090
2284
  if (slot.session !== session! || slot.arbitrationGeneration !== generation!) {
2091
2285
  throw new BrowserRuntimeError('ABORTED');
2092
2286
  }
@@ -2128,6 +2322,12 @@ export class BrowserRuntime {
2128
2322
  failure = error;
2129
2323
  }
2130
2324
  }
2325
+ if (slot.frameFailure?.session === session) {
2326
+ try { await slot.frameFailure.promise; } catch (error) {
2327
+ failed = true;
2328
+ failure = error;
2329
+ }
2330
+ }
2131
2331
  const errorCode = failed ? this.operationErrorCode(failure) : undefined;
2132
2332
  await this.emitAudit(Object.freeze({
2133
2333
  ...operationIdentity,
@@ -2455,10 +2655,10 @@ export class BrowserRuntime {
2455
2655
  ): IFrameRefreshRecord {
2456
2656
  if (
2457
2657
  slot.frameRefresh
2458
- || slot.frameSubscription !== subscription
2658
+ || slot.frameSubscriptions.get(lease.leaseId) !== subscription
2459
2659
  || subscription.closed
2460
2660
  || subscription.lease !== lease
2461
- || slot.lease !== lease
2661
+ || slot.leases.get(lease.leaseId) !== lease
2462
2662
  || slot.session !== session
2463
2663
  ) throw new BrowserRuntimeError('BUSY');
2464
2664
  const state = session.getState();
@@ -2467,7 +2667,7 @@ export class BrowserRuntime {
2467
2667
  !activeTab
2468
2668
  || !activeTab.active
2469
2669
  || activeTab.status !== 'open'
2470
- || !activeTab.streaming
2670
+ || state.status !== 'running'
2471
2671
  ) throw new BrowserRuntimeError('BUSY');
2472
2672
  const refresh: IFrameRefreshRecord = {
2473
2673
  lease,
@@ -2477,7 +2677,7 @@ export class BrowserRuntime {
2477
2677
  initialTabId: activeTab.id,
2478
2678
  initialGeneration: activeTab.generation,
2479
2679
  initialViewportRevision: state.viewportRevision,
2480
- startingHighestSequence: subscription.highestSequence,
2680
+ startingHighestSequence: slot.highestFrameSequence,
2481
2681
  pendingAcknowledgements: new Set(),
2482
2682
  refreshStarted: false,
2483
2683
  admissionClosed: false,
@@ -2527,17 +2727,17 @@ export class BrowserRuntime {
2527
2727
  }
2528
2728
 
2529
2729
  private async drainFrameRefreshOutstanding(refresh: IFrameRefreshRecord): Promise<void> {
2530
- const outstanding = [...refresh.subscription.outstanding.values()];
2531
- refresh.subscription.outstanding.clear();
2532
- for (const frame of outstanding) {
2533
- if (frame.timer) clearTimeout(frame.timer);
2534
- frame.timer = undefined;
2730
+ const slot = refresh.lease.slot;
2731
+ for (const subscription of slot.frameSubscriptions.values()) {
2732
+ for (const frame of subscription.outstanding.values()) {
2733
+ if (frame.timer) clearTimeout(frame.timer);
2734
+ }
2735
+ subscription.outstanding.clear();
2736
+ }
2737
+ const outcomes = await Promise.all([...slot.producerAcknowledgements]);
2738
+ if (outcomes.some((outcome) => outcome.status !== 'fulfilled')) {
2739
+ this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2535
2740
  }
2536
- await Promise.all(outstanding.map((frame) => this.beginFrameAcknowledgement(
2537
- frame.session,
2538
- frame.acknowledgement,
2539
- refresh,
2540
- )));
2541
2741
  }
2542
2742
 
2543
2743
  private validateFrameRefreshBoundary(
@@ -2569,25 +2769,23 @@ export class BrowserRuntime {
2569
2769
  refresh: IFrameRefreshRecord,
2570
2770
  identity: plugins.smartpuppeteer.ILiveBrowserFrameIdentity,
2571
2771
  ): Promise<void> {
2572
- const retirements: Array<Promise<TFrameAcknowledgementOutcome>> = [];
2573
- for (const [key, frame] of refresh.subscription.outstanding) {
2574
- const acknowledgement = frame.acknowledgement;
2575
- if (
2576
- acknowledgement.tabId === identity.tabId
2577
- && acknowledgement.generation === identity.generation
2578
- && acknowledgement.viewportRevision === identity.viewportRevision
2579
- && acknowledgement.sequence >= identity.sequence
2580
- ) continue;
2581
- refresh.subscription.outstanding.delete(key);
2582
- if (frame.timer) clearTimeout(frame.timer);
2583
- frame.timer = undefined;
2584
- retirements.push(this.beginFrameAcknowledgement(
2585
- frame.session,
2586
- acknowledgement,
2587
- refresh,
2588
- ));
2772
+ for (const subscription of refresh.lease.slot.frameSubscriptions.values()) {
2773
+ for (const [key, frame] of subscription.outstanding) {
2774
+ const acknowledgement = frame.acknowledgement;
2775
+ if (
2776
+ acknowledgement.tabId === identity.tabId
2777
+ && acknowledgement.generation === identity.generation
2778
+ && acknowledgement.viewportRevision === identity.viewportRevision
2779
+ && acknowledgement.sequence >= identity.sequence
2780
+ ) continue;
2781
+ subscription.outstanding.delete(key);
2782
+ if (frame.timer) clearTimeout(frame.timer);
2783
+ }
2784
+ }
2785
+ const outcomes = await Promise.all([...refresh.lease.slot.producerAcknowledgements]);
2786
+ if (outcomes.some((outcome) => outcome.status !== 'fulfilled')) {
2787
+ this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2589
2788
  }
2590
- await Promise.all(retirements);
2591
2789
  }
2592
2790
 
2593
2791
  private beginFrameAcknowledgement(
@@ -2621,14 +2819,14 @@ export class BrowserRuntime {
2621
2819
  refresh: IFrameRefreshRecord,
2622
2820
  ): Promise<void> {
2623
2821
  if (refresh.failure) {
2624
- await this.options.beforeFrameFailureTermination?.();
2625
- const claimed = await this.handleFrameFailure(
2626
- slot,
2627
- refresh.session,
2628
- refresh.incarnationGeneration,
2629
- refresh.lease,
2630
- refresh,
2631
- );
2822
+ let claimed: boolean;
2823
+ if (refresh.producerFailure) claimed = await refresh.producerFailure;
2824
+ else {
2825
+ await this.options.beforeFrameFailureTermination?.();
2826
+ claimed = await this.handleFrameFailure(
2827
+ slot, refresh.session, refresh.incarnationGeneration, refresh,
2828
+ );
2829
+ }
2632
2830
  if (!claimed) {
2633
2831
  this.abandonFrameRefresh(slot, refresh);
2634
2832
  throw new BrowserRuntimeError('ABORTED');
@@ -2643,7 +2841,7 @@ export class BrowserRuntime {
2643
2841
  this.requireValidLease(refresh.lease);
2644
2842
  current = slot.frameRefresh === refresh
2645
2843
  && !refresh.abandoned
2646
- && slot.frameSubscription === refresh.subscription
2844
+ && slot.frameSubscriptions.get(refresh.lease.leaseId) === refresh.subscription
2647
2845
  && !refresh.subscription.closed
2648
2846
  && slot.session === refresh.session
2649
2847
  && slot.incarnationGeneration === refresh.incarnationGeneration;
@@ -2671,7 +2869,7 @@ export class BrowserRuntime {
2671
2869
  refresh.abandoned = true;
2672
2870
  slot.frameRefresh = undefined;
2673
2871
  if (
2674
- slot.frameSubscription === refresh.subscription
2872
+ slot.frameSubscriptions.get(refresh.lease.leaseId) === refresh.subscription
2675
2873
  && !refresh.subscription.closed
2676
2874
  && slot.session === refresh.session
2677
2875
  && slot.incarnationGeneration === refresh.incarnationGeneration
@@ -2689,274 +2887,140 @@ export class BrowserRuntime {
2689
2887
  ): void {
2690
2888
  if (frame.timer || subscription.closed || !subscription.outstanding.has(key)) return;
2691
2889
  const timer = setTimeout(() => {
2692
- // A late application acknowledgement retires the entry exactly like oldest-first
2693
- // eviction: the frame is acknowledged toward the session and the lease is untouched.
2694
- this.trackCleanup(this.retireOutstandingFrame(subscription, key, false).then(() => undefined));
2890
+ // Viewer lag retires only its local window; producer acknowledgement already started.
2891
+ this.trackCleanup(this.retireOutstandingFrame(subscription, key).then(() => undefined));
2695
2892
  }, this.options.frameAcknowledgementTimeoutMs);
2696
2893
  timer.unref();
2697
2894
  frame.timer = timer;
2698
2895
  }
2699
2896
 
2700
- private matchingFrameRefresh(
2701
- slot: IResourceSlot,
2702
- subscription: IFrameSubscriptionRecord,
2703
- session: ILiveBrowserSessionLike,
2704
- incarnationGeneration: number,
2705
- ): IFrameRefreshRecord | undefined {
2706
- const refresh = slot.frameRefresh;
2707
- return refresh
2708
- && !refresh.abandoned
2709
- && refresh.subscription === subscription
2710
- && refresh.lease === subscription.lease
2711
- && refresh.session === session
2712
- && refresh.incarnationGeneration === incarnationGeneration
2713
- ? refresh
2714
- : undefined;
2715
- }
2716
-
2717
2897
  private handleSessionEvent(
2718
2898
  slot: IResourceSlot,
2719
2899
  session: ILiveBrowserSessionLike,
2720
2900
  event: plugins.smartpuppeteer.TLiveBrowserEvent,
2721
2901
  ): void {
2722
- if (slot.session && slot.session !== session) {
2723
- if (event.type === 'frame') {
2724
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, {
2725
- tabId: event.frame.tabId,
2726
- sequence: event.frame.sequence,
2727
- generation: event.frame.generation,
2728
- viewportRevision: event.frame.viewportRevision,
2729
- }));
2730
- }
2731
- return;
2732
- }
2733
- const subscription = slot.frameSubscription;
2734
- const refreshForSession = slot.frameRefresh
2735
- && !slot.frameRefresh.abandoned
2736
- && slot.frameRefresh.session === session
2737
- && slot.frameRefresh.incarnationGeneration === slot.incarnationGeneration
2738
- ? slot.frameRefresh
2739
- : undefined;
2902
+ if (slot.session && slot.session !== session) return;
2903
+ const incarnationGeneration = slot.incarnationGeneration;
2904
+ const refresh = slot.frameRefresh?.session === session && !slot.frameRefresh.abandoned
2905
+ ? slot.frameRefresh : undefined;
2740
2906
  if (event.type === 'frame') {
2741
2907
  let acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest;
2742
2908
  try {
2743
- if (!(event.frame.data instanceof Uint8Array)) {
2744
- throw new BrowserRuntimeError('INVALID_INPUT');
2745
- }
2909
+ if (!(event.frame.data instanceof Uint8Array)) throw new BrowserRuntimeError('INVALID_INPUT');
2746
2910
  acknowledgement = this.validateFrameAcknowledgement(event.frame);
2747
- } catch {
2748
- if (refreshForSession) {
2749
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2750
- return;
2911
+ if (acknowledgement.sequence <= slot.highestFrameSequence) {
2912
+ throw new BrowserRuntimeError('FRAME_STREAM_FAILED');
2751
2913
  }
2752
- const failedLease = slot.session === session ? (slot.lease ?? null) : null;
2753
- this.trackCleanup(this.handleFrameFailure(
2754
- slot,
2755
- session,
2756
- slot.incarnationGeneration,
2757
- failedLease,
2758
- ).then(() => undefined));
2759
- return;
2760
- }
2761
- if (!subscription || subscription.closed) {
2762
- if (refreshForSession) {
2763
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2764
- return;
2914
+ if (slot.producerAcknowledgements.size >= this.options.maxOutstandingFrames) {
2915
+ throw new BrowserRuntimeError('FRAME_STREAM_FAILED');
2765
2916
  }
2766
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
2767
- return;
2768
- }
2769
- const refresh = this.matchingFrameRefresh(
2770
- slot,
2771
- subscription,
2772
- session,
2773
- slot.incarnationGeneration,
2774
- );
2775
- if (refreshForSession && refreshForSession !== refresh) {
2776
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2777
- return;
2778
- }
2779
- try {
2780
- this.requireValidLease(subscription.lease);
2781
2917
  } catch {
2782
- if (refresh) {
2783
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2784
- return;
2785
- }
2786
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
2787
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
2918
+ if (refresh) this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2919
+ else this.trackCleanup(this.handleFrameFailure(slot, session, incarnationGeneration).then(() => undefined));
2788
2920
  return;
2789
2921
  }
2790
- const key = this.frameAcknowledgementKey(acknowledgement);
2791
- if (acknowledgement.sequence <= subscription.highestSequence) {
2792
- if (refresh) {
2922
+ slot.highestFrameSequence = acknowledgement.sequence;
2923
+ // The producer has one bounded ACK, independent of how quickly any viewer paints.
2924
+ const producerAcknowledgement = this.beginFrameAcknowledgement(
2925
+ session, acknowledgement, refresh,
2926
+ );
2927
+ slot.producerAcknowledgements.add(producerAcknowledgement);
2928
+ this.trackCleanup(producerAcknowledgement.then(async (outcome) => {
2929
+ slot.producerAcknowledgements.delete(producerAcknowledgement);
2930
+ if (outcome.status === 'fulfilled') return;
2931
+ if (refresh && slot.frameRefresh === refresh && !refresh.abandoned) {
2793
2932
  this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2794
2933
  return;
2795
2934
  }
2796
- this.trackCleanup(this.handleFrameFailure(
2797
- slot,
2798
- session,
2799
- slot.incarnationGeneration,
2800
- subscription.lease,
2801
- ).then(() => undefined));
2802
- return;
2803
- }
2804
- subscription.highestSequence = acknowledgement.sequence;
2935
+ await this.options.beforeFrameFailureTermination?.();
2936
+ await this.handleFrameFailure(slot, session, incarnationGeneration);
2937
+ }));
2805
2938
  if (event.frame.data.byteLength > this.options.maxFrameBytes) {
2806
- // An oversized frame is dropped, not fatal: the sequence watermark already advanced so
2807
- // later frames stay valid, and the subscriber is told without losing its lease.
2808
- if (refresh) {
2809
- this.failFrameRefresh(refresh, 'FRAME_TOO_LARGE');
2939
+ if (refresh) this.failFrameRefresh(refresh, 'FRAME_TOO_LARGE');
2940
+ for (const subscription of slot.frameSubscriptions.values()) {
2810
2941
  this.pushToSubscription(subscription, {
2811
2942
  type: 'error',
2812
- error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: event.frame.tabId },
2943
+ error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: acknowledgement.tabId },
2813
2944
  });
2814
- return;
2815
2945
  }
2816
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
2817
- this.pushToSubscription(subscription, {
2818
- type: 'error',
2819
- error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: event.frame.tabId },
2820
- });
2821
2946
  return;
2822
2947
  }
2823
- if (refresh?.admissionClosed) return;
2824
- const refreshAdmission = refresh && !refresh.admissionClosed && !refresh.abandoned
2825
- ? refresh
2826
- : undefined;
2827
- if (refreshAdmission) {
2828
- if (refreshAdmission.failure) return;
2948
+ if (refresh?.refreshStarted && !refresh.boundaryCandidate && !refresh.admissionClosed) {
2829
2949
  if (
2830
- subscription.outstanding.size + refreshAdmission.pendingAcknowledgements.size
2831
- >= this.options.maxOutstandingFrames
2832
- ) {
2833
- this.failFrameRefresh(refreshAdmission, 'FRAME_STREAM_FAILED');
2834
- return;
2835
- }
2836
- } else {
2837
- if (refresh && subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2838
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2839
- }
2840
- while (subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2841
- const oldestKey = subscription.outstanding.keys().next().value as string | undefined;
2842
- if (!oldestKey) break;
2843
- this.trackCleanup(
2844
- this.retireOutstandingFrame(subscription, oldestKey, false).then(() => undefined),
2845
- );
2846
- }
2950
+ acknowledgement.tabId === refresh.initialTabId
2951
+ && acknowledgement.sequence > refresh.startingHighestSequence
2952
+ && acknowledgement.generation > refresh.initialGeneration
2953
+ && acknowledgement.viewportRevision === refresh.initialViewportRevision
2954
+ ) refresh.boundaryCandidate = { ...acknowledgement };
2955
+ else this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2847
2956
  }
2848
- const outstanding: IOutstandingFrameRecord = {
2849
- acknowledgement,
2850
- session,
2851
- incarnationGeneration: slot.incarnationGeneration,
2852
- };
2853
- subscription.outstanding.set(key, outstanding);
2854
- if (!refreshAdmission) this.armFrameAcknowledgementTimer(subscription, key, outstanding);
2855
- const delivered = this.pushToSubscription(subscription, { type: 'frame', frame: event.frame });
2856
- if (
2857
- delivered
2858
- && refreshAdmission
2859
- && refreshAdmission.refreshStarted
2860
- && !refreshAdmission.boundaryCandidate
2861
- ) {
2862
- if (
2863
- acknowledgement.tabId === refreshAdmission.initialTabId
2864
- && acknowledgement.sequence > refreshAdmission.startingHighestSequence
2865
- && acknowledgement.generation > refreshAdmission.initialGeneration
2866
- && acknowledgement.viewportRevision === refreshAdmission.initialViewportRevision
2867
- ) {
2868
- refreshAdmission.boundaryCandidate = { ...acknowledgement };
2869
- } else {
2870
- this.failFrameRefresh(refreshAdmission, 'FRAME_STREAM_FAILED');
2957
+ const key = this.frameAcknowledgementKey(acknowledgement);
2958
+ for (const subscription of [...slot.frameSubscriptions.values()]) {
2959
+ if (subscription.closed) continue;
2960
+ while (subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2961
+ const oldestKey = subscription.outstanding.keys().next().value!;
2962
+ this.trackCleanup(this.retireOutstandingFrame(subscription, oldestKey).then(() => undefined));
2871
2963
  }
2964
+ const outstanding: IOutstandingFrameRecord = {
2965
+ acknowledgement, session, incarnationGeneration, producerAcknowledgement,
2966
+ };
2967
+ subscription.highestSequence = acknowledgement.sequence;
2968
+ subscription.outstanding.set(key, outstanding);
2969
+ this.armFrameAcknowledgementTimer(subscription, key, outstanding);
2970
+ this.pushToSubscription(subscription, { type: 'frame', frame: event.frame });
2872
2971
  }
2873
2972
  return;
2874
2973
  }
2875
- if (event.type === 'error') {
2876
- const runtimeEvent = {
2877
- type: 'error',
2878
- error: {
2879
- code: truncateString(event.error.code, 128),
2880
- fatal: event.error.fatal,
2881
- message: truncateString(event.error.message, 2048),
2882
- ...(event.error.tabId ? { tabId: truncateString(event.error.tabId, 128) } : {}),
2883
- },
2884
- } satisfies TBrowserRuntimeEvent;
2885
- let fatalLease = event.error.fatal && slot.lease?.capability.state === 'active'
2886
- ? slot.lease
2887
- : undefined;
2888
- if (fatalLease) {
2889
- try {
2890
- this.requireValidLease(fatalLease);
2891
- } catch {
2892
- fatalLease = undefined;
2893
- }
2974
+ const runtimeEvent: TBrowserRuntimeEvent = event.type === 'state'
2975
+ ? { type: 'state', state: this.resourceState(event.state) }
2976
+ : {
2977
+ type: 'error',
2978
+ error: {
2979
+ code: truncateString(event.error.code, 128),
2980
+ fatal: event.error.fatal,
2981
+ message: truncateString(event.error.message, 2048),
2982
+ ...(event.error.tabId ? { tabId: truncateString(event.error.tabId, 128) } : {}),
2983
+ },
2984
+ };
2985
+ if (event.type === 'error' && event.error.fatal) {
2986
+ const subscribers = [...slot.frameSubscriptions.values()].filter((subscription) => (
2987
+ !subscription.closed && !subscription.lease.released
2988
+ ));
2989
+ // Publish the cleanup promise before synchronous revocation wakes queued callers.
2990
+ if (refresh) this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2991
+ const failure = this.handleFrameFailure(slot, session, incarnationGeneration);
2992
+ if (refresh) refresh.producerFailure = failure;
2993
+ for (const lease of slot.leases.values()) {
2994
+ if (lease.released) continue;
2995
+ this.invalidateCapabilityRecord(lease.capability);
2996
+ lease.released = true;
2997
+ lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
2894
2998
  }
2895
- if (event.error.fatal && !fatalLease) return;
2896
- if (fatalLease) {
2897
- const fatalSubscription = subscription
2898
- && !subscription.closed
2899
- && subscription.lease === fatalLease
2900
- ? subscription
2901
- : undefined;
2902
- if (refreshForSession?.lease === fatalLease) {
2903
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2904
- }
2905
- this.invalidateCapabilityRecord(fatalLease.capability);
2906
- try {
2907
- fatalSubscription?.listener(runtimeEvent);
2908
- } catch {
2909
- // Fatal revocation already owns cleanup for a failing listener.
2910
- } finally {
2911
- this.trackCleanup(this.revokeCapabilityRecord(fatalLease.capability));
2912
- }
2913
- return;
2999
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3000
+ for (const subscription of subscribers) {
3001
+ try { subscription.listener(runtimeEvent); } catch { /* Producer failure owns cleanup. */ }
2914
3002
  }
2915
- if (!subscription || subscription.closed) return;
2916
- this.pushToSubscription(subscription, runtimeEvent);
3003
+ this.trackCleanup(failure.then(() => undefined));
2917
3004
  return;
2918
3005
  }
2919
- if (!subscription || subscription.closed) return;
2920
- this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(event.state) });
3006
+ for (const subscription of [...slot.frameSubscriptions.values()]) {
3007
+ this.pushToSubscription(subscription, runtimeEvent);
3008
+ }
2921
3009
  }
2922
3010
 
2923
3011
  private pushToSubscription(
2924
3012
  subscription: IFrameSubscriptionRecord,
2925
3013
  event: TBrowserRuntimeEvent,
2926
3014
  ): boolean {
3015
+ if (subscription.closed) return false;
2927
3016
  try {
2928
3017
  this.requireValidLease(subscription.lease);
2929
3018
  subscription.listener(event);
2930
3019
  return true;
2931
3020
  } catch {
2932
- const slot = subscription.lease.slot;
2933
- const session = slot.session;
2934
- const incarnationGeneration = slot.incarnationGeneration;
2935
- const refresh = session
2936
- ? this.matchingFrameRefresh(slot, subscription, session, incarnationGeneration)
2937
- : undefined;
2938
- if (event.type === 'frame') {
2939
- const key = this.frameAcknowledgementKey(this.validateFrameAcknowledgement(event.frame));
2940
- this.trackCleanup(this.retireOutstandingFrame(subscription, key, true).then(() => undefined));
2941
- return false;
2942
- }
2943
- if (refresh) {
2944
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2945
- return false;
2946
- }
2947
- if (!session) {
2948
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
2949
- return false;
2950
- }
2951
- this.trackCleanup((async () => {
2952
- await this.options.beforeFrameFailureTermination?.();
2953
- await this.handleFrameFailure(
2954
- slot,
2955
- session,
2956
- incarnationGeneration,
2957
- subscription.lease,
2958
- );
2959
- })());
3021
+ // A failed participant transport cannot revoke another participant or kill the producer.
3022
+ this.trackCleanup(this.closeFrameSubscription(subscription));
3023
+ this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
2960
3024
  return false;
2961
3025
  }
2962
3026
  }
@@ -2965,60 +3029,28 @@ export class BrowserRuntime {
2965
3029
  if (subscription.closed) return;
2966
3030
  subscription.closed = true;
2967
3031
  const slot = subscription.lease.slot;
2968
- const refresh = slot.frameRefresh;
2969
- if (refresh?.subscription === subscription && !refresh.abandoned) {
2970
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3032
+ if (slot.frameRefresh?.subscription === subscription) {
3033
+ this.abandonFrameRefresh(slot, slot.frameRefresh);
2971
3034
  }
2972
- if (slot.frameSubscription === subscription) slot.frameSubscription = undefined;
2973
- for (const key of [...subscription.outstanding.keys()]) {
2974
- await this.retireOutstandingFrame(subscription, key, false);
3035
+ if (slot.frameSubscriptions.get(subscription.lease.leaseId) === subscription) {
3036
+ slot.frameSubscriptions.delete(subscription.lease.leaseId);
2975
3037
  }
3038
+ for (const frame of subscription.outstanding.values()) {
3039
+ if (frame.timer) clearTimeout(frame.timer);
3040
+ }
3041
+ subscription.outstanding.clear();
2976
3042
  }
2977
3043
 
2978
3044
  private async retireOutstandingFrame(
2979
3045
  subscription: IFrameSubscriptionRecord,
2980
3046
  key: string,
2981
- failLease: boolean,
2982
3047
  ): Promise<boolean> {
2983
3048
  const outstanding = subscription.outstanding.get(key);
2984
3049
  if (!outstanding) return false;
2985
3050
  subscription.outstanding.delete(key);
2986
3051
  if (outstanding.timer) clearTimeout(outstanding.timer);
2987
- outstanding.timer = undefined;
2988
- const slot = subscription.lease.slot;
2989
- const refresh = this.matchingFrameRefresh(
2990
- slot,
2991
- subscription,
2992
- outstanding.session,
2993
- outstanding.incarnationGeneration,
2994
- );
2995
- if (refresh?.admissionClosed) {
2996
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2997
- return false;
2998
- }
2999
- if (refresh && failLease) this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3000
- const refreshOwned = Boolean(refresh && !refresh.abandoned);
3001
- const outcome = await this.beginFrameAcknowledgement(
3002
- outstanding.session,
3003
- outstanding.acknowledgement,
3004
- refreshOwned ? refresh : undefined,
3005
- );
3006
- if (outcome.status === 'fulfilled' && !failLease) return outcome.accepted;
3007
- if (refreshOwned) return outcome.status === 'fulfilled' ? outcome.accepted : false;
3008
- if (refresh && slot.frameRefresh === refresh) {
3009
- if (outcome.status !== 'fulfilled') {
3010
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3011
- }
3012
- return outcome.status === 'fulfilled' ? outcome.accepted : false;
3013
- }
3014
- await this.options.beforeFrameFailureTermination?.();
3015
- await this.handleFrameFailure(
3016
- slot,
3017
- outstanding.session,
3018
- outstanding.incarnationGeneration,
3019
- subscription.lease,
3020
- );
3021
- return outcome.status === 'fulfilled' ? outcome.accepted : false;
3052
+ const outcome = await outstanding.producerAcknowledgement;
3053
+ return outcome.status === 'fulfilled' && outcome.accepted;
3022
3054
  }
3023
3055
 
3024
3056
  private validateFrameAcknowledgement(
@@ -3080,130 +3112,73 @@ export class BrowserRuntime {
3080
3112
  }
3081
3113
  }
3082
3114
 
3083
- private async acknowledgeFrameInBackground(
3115
+ private async handleFrameFailure(
3084
3116
  slot: IResourceSlot,
3085
3117
  session: ILiveBrowserSessionLike,
3086
- acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
3087
- failLease = false,
3088
- ): Promise<void> {
3089
- const failedLease = slot.session === session ? (slot.lease ?? null) : null;
3090
- const incarnationGeneration = slot.incarnationGeneration;
3091
- const subscription = slot.frameSubscription;
3092
- const refresh = subscription
3093
- ? this.matchingFrameRefresh(slot, subscription, session, incarnationGeneration)
3094
- : undefined;
3095
- if (refresh && failLease) this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3096
- const refreshOwned = Boolean(refresh && !refresh.admissionClosed && !refresh.abandoned);
3097
- const outcome = await this.beginFrameAcknowledgement(
3098
- session,
3099
- acknowledgement,
3100
- refreshOwned ? refresh : undefined,
3101
- );
3102
- if (outcome.status === 'fulfilled' && !failLease) return;
3103
- if (refreshOwned) return;
3104
- if (refresh && slot.frameRefresh === refresh) {
3105
- if (outcome.status !== 'fulfilled') {
3106
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3107
- }
3108
- return;
3118
+ incarnationGeneration: number,
3119
+ expectedRefresh?: IFrameRefreshRecord,
3120
+ ): Promise<boolean> {
3121
+ if (slot.session !== session || slot.incarnationGeneration !== incarnationGeneration) return false;
3122
+ if (slot.frameFailure?.session === session) return slot.frameFailure.promise;
3123
+ const promise = this.handleFrameFailureInternal(slot, session, incarnationGeneration, expectedRefresh);
3124
+ const failure = { session, promise };
3125
+ slot.frameFailure = failure;
3126
+ try { return await promise; } finally {
3127
+ if (slot.frameFailure === failure) slot.frameFailure = undefined;
3109
3128
  }
3110
- await this.options.beforeFrameFailureTermination?.();
3111
- await this.handleFrameFailure(slot, session, incarnationGeneration, failedLease);
3112
3129
  }
3113
3130
 
3114
- private async handleFrameFailure(
3131
+ private async handleFrameFailureInternal(
3115
3132
  slot: IResourceSlot,
3116
3133
  session: ILiveBrowserSessionLike,
3117
3134
  incarnationGeneration: number,
3118
- expectedLease: ILeaseRecord | null,
3119
3135
  expectedRefresh?: IFrameRefreshRecord,
3120
3136
  ): Promise<boolean> {
3121
- let transitionGeneration = 0;
3122
- let ownsTransitionFence = false;
3123
- const lease = expectedLease ?? undefined;
3124
3137
  const release = await slot.mutex.acquire();
3138
+ let generation: number;
3139
+ let leases: ILeaseRecord[];
3125
3140
  try {
3126
- const exactLeaseIsCurrent = expectedLease === null
3127
- ? slot.lease === undefined
3128
- : slot.lease === expectedLease && expectedLease.capability.lease === expectedLease;
3129
- let leaseAttachmentIsCurrent = true;
3130
- if (lease) {
3131
- try {
3132
- this.assertCapabilityStillAttached(lease.capability, slot);
3133
- } catch {
3134
- leaseAttachmentIsCurrent = false;
3135
- }
3136
- }
3137
- const borrowsLeaseReleaseFence = Boolean(
3138
- lease
3139
- && exactLeaseIsCurrent
3140
- && lease.releaseGeneration !== undefined
3141
- && slot.fencingGeneration === lease.releaseGeneration,
3142
- );
3143
3141
  if (
3144
3142
  slot.session !== session
3145
3143
  || slot.incarnationGeneration !== incarnationGeneration
3146
- || slot.permanentlyFenced
3147
- || slot.retirementPending
3148
- || slot.terminationFenceGeneration !== undefined
3149
- || slot.attachmentFenceCount > 0
3150
- || slot.attachmentRetryBinding
3151
- || !exactLeaseIsCurrent
3152
- || !leaseAttachmentIsCurrent
3153
- || (expectedRefresh !== undefined && slot.frameRefresh !== expectedRefresh)
3154
- || (slot.fencingGeneration !== undefined && !borrowsLeaseReleaseFence)
3144
+ || slot.permanentlyFenced || slot.retirementPending
3145
+ || slot.terminationFenceGeneration !== undefined || slot.fencingGeneration !== undefined
3146
+ || (expectedRefresh && slot.frameRefresh !== expectedRefresh)
3155
3147
  ) return false;
3156
- if (expectedRefresh) {
3157
- expectedRefresh.admissionClosed = true;
3158
- expectedRefresh.abandoned = true;
3159
- slot.frameRefresh = undefined;
3160
- }
3161
- if (lease) this.invalidateCapabilityRecord(lease.capability);
3162
- if (borrowsLeaseReleaseFence) {
3163
- transitionGeneration = slot.fencingGeneration!;
3164
- } else {
3165
- transitionGeneration = ++slot.arbitrationGeneration;
3166
- slot.fencingGeneration = transitionGeneration;
3167
- ownsTransitionFence = true;
3168
- }
3169
- if (lease) {
3148
+ generation = ++slot.arbitrationGeneration;
3149
+ slot.fencingGeneration = generation;
3150
+ this.abandonFrameRefresh(slot);
3151
+ leases = [...slot.leases.values()];
3152
+ for (const lease of leases) {
3153
+ this.invalidateCapabilityRecord(lease.capability);
3170
3154
  lease.released = true;
3171
3155
  lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3156
+ this.notifyFramedLeaseEnded(lease);
3172
3157
  }
3173
- if (!lease || slot.operation?.lease === lease) {
3174
- slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3175
- }
3158
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3176
3159
  } finally {
3177
3160
  release();
3178
3161
  }
3179
- if (lease) this.trackFrameCapabilityRevocation(lease);
3180
3162
  await this.options.afterFrameFailureFence?.();
3181
- if (lease) await this.closeFrameSubscriptionIfLease(lease);
3163
+ await Promise.all(leases.map((lease) => this.closeFrameSubscriptionIfLease(lease)));
3182
3164
  const errors: unknown[] = [];
3183
3165
  await this.quiesceSlot(slot).catch((error) => errors.push(error));
3184
3166
  await this.terminateSlotSession(slot, session).catch((error) => errors.push(error));
3185
3167
  const finalRelease = await slot.mutex.acquire();
3186
3168
  try {
3187
- if (
3188
- slot.arbitrationGeneration === transitionGeneration
3189
- && slot.fencingGeneration === transitionGeneration
3190
- && slot.incarnationGeneration === incarnationGeneration
3191
- && slot.session !== session
3192
- ) {
3193
- if (ownsTransitionFence && lease && slot.lease === lease) slot.lease = undefined;
3194
- if (ownsTransitionFence && lease && lease.capability.lease === lease) {
3195
- lease.capability.lease = undefined;
3169
+ if (slot.fencingGeneration === generation && errors.length === 0) {
3170
+ for (const lease of leases) {
3171
+ slot.leases.delete(lease.leaseId);
3172
+ if (lease.capability.lease === lease) lease.capability.lease = undefined;
3196
3173
  }
3197
- if (!lease || slot.operation?.lease === lease) slot.operation = undefined;
3198
- if (ownsTransitionFence && errors.length === 0) slot.fencingGeneration = undefined;
3174
+ slot.operation = undefined;
3175
+ slot.fencingGeneration = undefined;
3199
3176
  }
3200
3177
  } finally {
3201
3178
  finalRelease();
3202
3179
  }
3203
- if (errors.length === 1) throw errors[0];
3204
- if (errors.length > 1) {
3205
- throw new AggregateError(errors, 'Frame acknowledgement cleanup is incomplete');
3206
- }
3180
+ for (const lease of leases) this.trackFrameCapabilityRevocation(lease);
3181
+ if (errors.length) throw new AggregateError(errors, 'Frame producer cleanup is incomplete');
3207
3182
  return true;
3208
3183
  }
3209
3184
 
@@ -3216,6 +3191,14 @@ export class BrowserRuntime {
3216
3191
  this.trackCleanup(operation);
3217
3192
  }
3218
3193
 
3194
+ private requireQueuedLease(lease: ILeaseRecord, participantCleanup = false): void {
3195
+ if (!participantCleanup) return this.requireValidLease(lease);
3196
+ if (
3197
+ !lease.released || lease.slot.leases.get(lease.leaseId) !== lease
3198
+ || lease.capability.lease !== lease || !lease.slot.session
3199
+ ) throw new BrowserRuntimeError('CAPABILITY_REVOKED');
3200
+ }
3201
+
3219
3202
  private requireValidLease(lease: ILeaseRecord): void {
3220
3203
  if (lease.released || lease.controller.signal.aborted) {
3221
3204
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
@@ -3227,7 +3210,7 @@ export class BrowserRuntime {
3227
3210
  if (
3228
3211
  capability !== lease.capability
3229
3212
  || capability.lease !== lease
3230
- || lease.slot.lease !== lease
3213
+ || lease.slot.leases.get(lease.leaseId) !== lease
3231
3214
  || !lease.slot.session
3232
3215
  ) {
3233
3216
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
@@ -3350,57 +3333,58 @@ export class BrowserRuntime {
3350
3333
  }
3351
3334
 
3352
3335
  private async releaseLeaseRecordInternal(lease: ILeaseRecord): Promise<void> {
3353
- if (lease.released && lease.slot.lease !== lease) return;
3354
3336
  const slot = lease.slot;
3337
+ if (slot.leases.get(lease.leaseId) !== lease) return;
3355
3338
  const release = await slot.mutex.acquire();
3356
- const existingFence = slot.fencingGeneration;
3357
- const generation = lease.releaseGeneration
3358
- ?? existingFence
3359
- ?? ++slot.arbitrationGeneration;
3360
- lease.releaseGeneration = generation;
3361
- lease.releaseOwnsFence ??= existingFence === undefined;
3339
+ let operation: IOperationRecord | undefined;
3362
3340
  try {
3363
- if (existingFence !== undefined) {
3364
- lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3365
- } else {
3366
- slot.fencingGeneration = generation;
3367
- }
3368
3341
  lease.released = true;
3369
3342
  lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3370
- if (slot.operation?.lease === lease) {
3371
- slot.operation.controller.abort(new BrowserRuntimeError('ABORTED'));
3372
- }
3343
+ operation = slot.operation?.lease === lease ? slot.operation : undefined;
3344
+ operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
3373
3345
  } finally {
3374
3346
  release();
3375
3347
  }
3376
3348
  await this.closeFrameSubscriptionIfLease(lease);
3377
- await this.quiesceSlot(slot);
3378
- if (slot.frameRefresh?.lease === lease) {
3379
- this.abandonFrameRefresh(slot, slot.frameRefresh);
3349
+ if (operation) {
3350
+ const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
3351
+ if (!settled.settled) {
3352
+ // An unquiescent operation belongs to the browser process, so its incarnation must end.
3353
+ await this.terminateRegisteredResourceInternal(slot, false, {
3354
+ session: operation.session, incarnationGeneration: operation.incarnationGeneration,
3355
+ });
3356
+ }
3380
3357
  }
3358
+ if (slot.frameRefresh?.lease === lease) this.abandonFrameRefresh(slot, slot.frameRefresh);
3381
3359
  if (
3382
- slot.permanentlyFenced
3383
- && (slot.session || slot.proxy || slot.profileDirectory)
3384
- ) await this.terminateSlotSession(slot);
3360
+ this.lifecycleState === 'running' && slot.session && !slot.permanentlyFenced && !slot.retirementPending
3361
+ && slot.fencingGeneration === undefined && slot.terminationFenceGeneration === undefined
3362
+ && (lease.preferredViewport || lease.heldKeys.size || lease.heldButtons.size)
3363
+ ) {
3364
+ await this.runOperation(lease, 'releaseParticipant', 'viewport', {},
3365
+ async (signal, session) => {
3366
+ await this.releaseParticipantInput(lease, session);
3367
+ await this.applySharedViewport(slot, session, signal);
3368
+ lease.preferredViewport = undefined;
3369
+ }, undefined, true);
3370
+ }
3371
+ if (slot.permanentlyFenced && (slot.session || slot.proxy || slot.profileDirectory)) {
3372
+ await this.terminateSlotSession(slot);
3373
+ }
3385
3374
  const finalRelease = await slot.mutex.acquire();
3386
3375
  try {
3387
3376
  if (lease.capability.state === 'active') this.notifyFramedLeaseEnded(lease);
3388
- if (slot.lease === lease) slot.lease = undefined;
3377
+ slot.leases.delete(lease.leaseId);
3389
3378
  if (lease.capability.lease === lease) lease.capability.lease = undefined;
3390
- if (lease.releaseOwnsFence && slot.fencingGeneration === generation) {
3391
- slot.fencingGeneration = undefined;
3392
- }
3393
- lease.releaseGeneration = undefined;
3394
- lease.releaseOwnsFence = undefined;
3395
- this.scheduleIdleTermination(slot);
3379
+ if (slot.leases.size === 0) this.scheduleIdleTermination(slot);
3396
3380
  } finally {
3397
3381
  finalRelease();
3398
3382
  }
3399
3383
  }
3400
3384
 
3401
3385
  private async closeFrameSubscriptionIfLease(lease: ILeaseRecord): Promise<void> {
3402
- const subscription = lease.slot.frameSubscription;
3403
- if (subscription?.lease === lease) await this.closeFrameSubscription(subscription);
3386
+ const subscription = lease.slot.frameSubscriptions.get(lease.leaseId);
3387
+ if (subscription) await this.closeFrameSubscription(subscription);
3404
3388
  }
3405
3389
 
3406
3390
  private async quiesceSlot(slot: IResourceSlot): Promise<void> {
@@ -3436,6 +3420,12 @@ export class BrowserRuntime {
3436
3420
  return;
3437
3421
  }
3438
3422
  const slot = operation.lease.slot;
3423
+ if (slot.operation === operation && slot.session === operation.session
3424
+ && slot.incarnationGeneration === operation.incarnationGeneration) {
3425
+ await this.terminateRegisteredResourceInternal(slot, false, {
3426
+ session: operation.session, incarnationGeneration: operation.incarnationGeneration,
3427
+ });
3428
+ }
3439
3429
  const release = await slot.mutex.acquire();
3440
3430
  try {
3441
3431
  if (
@@ -3488,6 +3478,8 @@ export class BrowserRuntime {
3488
3478
  slot.unsubscribeSession?.();
3489
3479
  slot.unsubscribeSession = undefined;
3490
3480
  slot.session = undefined;
3481
+ slot.highestFrameSequence = 0;
3482
+ slot.producerAcknowledgements.clear();
3491
3483
  }
3492
3484
  if (slot.proxy) {
3493
3485
  await this.stopProxyBounded(slot.proxy).then(
@@ -3505,7 +3497,7 @@ export class BrowserRuntime {
3505
3497
  }
3506
3498
 
3507
3499
  private scheduleIdleTermination(slot: IResourceSlot): void {
3508
- if (slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
3500
+ if (slot.leases.size > 0 || slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
3509
3501
  if (slot.idleTimer) clearTimeout(slot.idleTimer);
3510
3502
  const arbitrationGeneration = slot.arbitrationGeneration;
3511
3503
  const incarnationGeneration = slot.incarnationGeneration;
@@ -3549,7 +3541,7 @@ export class BrowserRuntime {
3549
3541
  ): Promise<void> {
3550
3542
  const release = await slot.mutex.acquire();
3551
3543
  let generation = 0;
3552
- let attachmentFenceOwnsTransition = false;
3544
+ let leases: ILeaseRecord[];
3553
3545
  try {
3554
3546
  if (
3555
3547
  (expected?.arbitrationGeneration !== undefined
@@ -3557,48 +3549,34 @@ export class BrowserRuntime {
3557
3549
  || (expected?.incarnationGeneration !== undefined
3558
3550
  && slot.incarnationGeneration !== expected.incarnationGeneration)
3559
3551
  || (expected?.session !== undefined && slot.session !== expected.session)
3560
- || (expected?.requireIdle && slot.lease)
3552
+ || (expected?.requireIdle && slot.leases.size > 0)
3561
3553
  ) return;
3562
- attachmentFenceOwnsTransition = slot.attachmentRetryBinding !== undefined;
3563
- if (!attachmentFenceOwnsTransition) {
3564
- generation = ++slot.arbitrationGeneration;
3565
- slot.fencingGeneration = generation;
3566
- }
3567
- if (slot.idleTimer) {
3568
- clearTimeout(slot.idleTimer);
3569
- slot.idleTimer = undefined;
3570
- }
3571
- if (slot.lease) {
3572
- slot.lease.released = true;
3573
- slot.lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3574
- if (!stopping && !attachmentFenceOwnsTransition) {
3575
- this.notifyFramedLeaseEnded(slot.lease);
3576
- }
3554
+ generation = ++slot.arbitrationGeneration;
3555
+ slot.fencingGeneration = generation;
3556
+ if (slot.idleTimer) clearTimeout(slot.idleTimer);
3557
+ slot.idleTimer = undefined;
3558
+ leases = [...slot.leases.values()];
3559
+ for (const lease of leases) {
3560
+ lease.released = true;
3561
+ lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3562
+ if (!stopping) this.notifyFramedLeaseEnded(lease);
3577
3563
  }
3578
3564
  slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
3579
3565
  } finally {
3580
3566
  release();
3581
3567
  }
3582
- if (attachmentFenceOwnsTransition) {
3583
- await this.quiesceSlot(slot);
3584
- if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
3585
- await this.terminateSlotSession(slot, expected?.session);
3586
- return;
3587
- }
3588
- if (generation === 0) return;
3589
3568
  await this.quiesceSlot(slot);
3590
- if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
3569
+ await Promise.all(leases.map((lease) => this.closeFrameSubscriptionIfLease(lease)));
3591
3570
  await this.terminateSlotSession(slot, expected?.session);
3592
3571
  const finalRelease = await slot.mutex.acquire();
3593
3572
  try {
3594
3573
  if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
3595
3574
  throw new BrowserRuntimeError('BUSY');
3596
3575
  }
3597
- const lease = slot.lease;
3598
- if (lease && lease.capability.lease === lease) {
3599
- lease.capability.lease = undefined;
3576
+ for (const lease of leases) {
3577
+ if (lease.capability.lease === lease) lease.capability.lease = undefined;
3578
+ slot.leases.delete(lease.leaseId);
3600
3579
  }
3601
- slot.lease = undefined;
3602
3580
  slot.operation = undefined;
3603
3581
  slot.fencingGeneration = undefined;
3604
3582
  } finally {
@@ -3642,7 +3620,6 @@ export class BrowserRuntime {
3642
3620
  if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
3643
3621
  if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
3644
3622
  if (slot.terminationFenceGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
3645
- if (slot.attachmentFenceCount > 0) throw new BrowserRuntimeError('BUSY');
3646
3623
  if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
3647
3624
  }
3648
3625
 
@@ -3685,54 +3662,45 @@ export class BrowserRuntime {
3685
3662
  if (binding.attachmentRevision < current.attachmentRevision) {
3686
3663
  throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3687
3664
  }
3688
- if (
3689
- binding.attachmentRevision === current.attachmentRevision
3690
- && !slot.attachmentRetryBinding
3691
- ) {
3692
- if (!this.bindingsEqual(binding, current)) {
3693
- throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3694
- }
3665
+ if (binding.attachmentRevision === current.attachmentRevision && !slot.attachmentRetryBinding) {
3666
+ if (!this.bindingsEqual(binding, current)) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3695
3667
  return this.describeResource(slot);
3696
3668
  }
3697
- if (
3698
- slot.attachmentRetryBinding
3699
- && !this.bindingsEqual(binding, slot.attachmentRetryBinding)
3700
- ) throw new BrowserRuntimeError('BUSY');
3669
+ if (slot.attachmentRetryBinding && !this.bindingsEqual(binding, slot.attachmentRetryBinding)) {
3670
+ throw new BrowserRuntimeError('BUSY');
3671
+ }
3701
3672
  const release = await slot.mutex.acquire();
3702
- let fenceGeneration = 0;
3703
3673
  try {
3704
- fenceGeneration = ++slot.arbitrationGeneration;
3705
- slot.fencingGeneration = fenceGeneration;
3706
3674
  slot.attachmentBinding = binding;
3707
3675
  slot.attachmentRetryBinding = binding;
3708
- slot.lease?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3709
- slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3676
+ for (const lease of slot.leases.values()) {
3677
+ if (lease.role === 'agent') {
3678
+ lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3679
+ }
3680
+ }
3681
+ if (slot.operation?.lease.role === 'agent') {
3682
+ slot.operation.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3683
+ }
3710
3684
  } finally {
3711
3685
  release();
3712
3686
  }
3713
3687
  const peerResults = await Promise.allSettled(
3714
3688
  this.framedPeersForResource(slot).map((peer) => peer.close()),
3715
3689
  );
3716
- const capabilities = this.capabilitiesForResource(slot);
3717
3690
  const capabilityResults = await Promise.allSettled(
3718
- capabilities.map((record) => this.revokeCapabilityRecord(record)),
3691
+ this.capabilitiesForResource(slot).filter((record) => record.role === 'agent')
3692
+ .map((record) => this.revokeCapabilityRecord(record)),
3719
3693
  );
3720
3694
  const errors = [...peerResults, ...capabilityResults]
3721
3695
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
3722
3696
  .map((result) => result.reason);
3723
- await this.quiesceSlot(slot).catch((error) => errors.push(error));
3724
3697
  const finalRelease = await slot.mutex.acquire();
3725
3698
  try {
3726
- if (slot.fencingGeneration === fenceGeneration && errors.length === 0) {
3727
- slot.fencingGeneration = undefined;
3728
- slot.attachmentRetryBinding = undefined;
3729
- }
3699
+ if (errors.length === 0) slot.attachmentRetryBinding = undefined;
3730
3700
  } finally {
3731
3701
  finalRelease();
3732
3702
  }
3733
- if (errors.length > 0) {
3734
- throw new AggregateError(errors, 'Attachment fencing is incomplete');
3735
- }
3703
+ if (errors.length) throw new AggregateError(errors, 'Attachment fencing is incomplete');
3736
3704
  return this.describeResource(slot);
3737
3705
  }
3738
3706
 
@@ -3902,6 +3870,9 @@ export class BrowserRuntime {
3902
3870
  || binding.attachmentRevision !== expected.attachmentRevision
3903
3871
  ) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3904
3872
  if (expected.role === 'agent') {
3873
+ if (slot.attachmentFenceCount > 0 || slot.attachmentRetryBinding) {
3874
+ throw new BrowserRuntimeError('BUSY');
3875
+ }
3905
3876
  if (!binding.sessionId || !this.sessionsEqual(binding.sessionId, expected.sessionId)) {
3906
3877
  throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3907
3878
  }
@@ -3918,6 +3889,7 @@ export class BrowserRuntime {
3918
3889
  capability.projectId !== slot.projectId
3919
3890
  || capability.browserResourceId !== slot.browserResourceId
3920
3891
  ) throw new BrowserRuntimeError('CAPABILITY_INVALID');
3892
+ if (capability.role === 'human') return;
3921
3893
  try {
3922
3894
  this.assertCapabilityBinding(slot, capability);
3923
3895
  } catch {
@@ -3950,7 +3922,7 @@ export class BrowserRuntime {
3950
3922
  || sessionId?.harnessId !== 'flex'))
3951
3923
  || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
3952
3924
  || runId !== undefined
3953
- || sessionId?.harnessId !== 'opencode'))
3925
+ || sessionId === undefined))
3954
3926
  || (source === 'human' && (scopeId !== undefined || channelId !== undefined
3955
3927
  || runId !== undefined))
3956
3928
  ) throw new BrowserRuntimeError('INVALID_INPUT');
@@ -3984,7 +3956,8 @@ export class BrowserRuntime {
3984
3956
  runId: runId!,
3985
3957
  };
3986
3958
  }
3987
- if (role === 'agent' && source === 'mcp' && sessionId?.harnessId === 'opencode') {
3959
+ if (role === 'agent' && source === 'mcp'
3960
+ && sessionId !== undefined) {
3988
3961
  return { ...base, role, source, sessionId };
3989
3962
  }
3990
3963
  throw new BrowserRuntimeError('INVALID_INPUT');
@@ -4022,7 +3995,7 @@ export class BrowserRuntime {
4022
3995
  if (
4023
3996
  capability.role === 'agent'
4024
3997
  && capability.source === 'mcp'
4025
- && capability.sessionId?.harnessId === 'opencode'
3998
+ && capability.sessionId !== undefined
4026
3999
  ) {
4027
4000
  return {
4028
4001
  ...base,
@@ -4128,6 +4101,15 @@ export class BrowserRuntimeLease {
4128
4101
  return this.record.capability.capabilityId;
4129
4102
  }
4130
4103
 
4104
+ public get expiresAt(): number {
4105
+ return this.record.capability.expiresAt;
4106
+ }
4107
+
4108
+ /** Reauthorize this exact live lease without replacing its frame stream or browser incarnation. */
4109
+ public renew(options?: IBrowserRuntimeLeaseRenewalOptions): Promise<number> {
4110
+ return this.runtime.renewLease(this.record, options);
4111
+ }
4112
+
4131
4113
  public get role(): TBrowserActorRole {
4132
4114
  return this.record.role;
4133
4115
  }
@@ -4219,7 +4201,7 @@ export class BrowserRuntimeLease {
4219
4201
  public setViewport(
4220
4202
  viewport: plugins.smartpuppeteer.ILiveBrowserViewport,
4221
4203
  operationOptions?: IBrowserRuntimeOperationOptions,
4222
- ): Promise<void> {
4204
+ ): Promise<IBrowserRuntimeViewportResult> {
4223
4205
  return this.runtime.leaseSetViewport(this.record, viewport, operationOptions);
4224
4206
  }
4225
4207