@modelprofile.com/browser-runtime 4.4.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,9 @@ 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>;
140
143
  }
141
144
 
142
145
  export interface IBrowserRuntimeLeaseRenewalOptions {
@@ -171,6 +174,8 @@ interface IQueuedOperationRecord {
171
174
  reject(error: unknown): void;
172
175
  state: 'queued' | 'starting' | 'active' | 'settled';
173
176
  onQueuedAbort?: () => void;
177
+ /** Resource-owned cleanup, admitted only for this exact released participant. */
178
+ participantCleanup?: boolean;
174
179
  }
175
180
 
176
181
  interface IOutstandingFrameRecord {
@@ -178,6 +183,7 @@ interface IOutstandingFrameRecord {
178
183
  timer?: ReturnType<typeof setTimeout>;
179
184
  session: ILiveBrowserSessionLike;
180
185
  incarnationGeneration: number;
186
+ producerAcknowledgement: Promise<TFrameAcknowledgementOutcome>;
181
187
  }
182
188
 
183
189
  type TFrameAcknowledgementOutcome =
@@ -208,6 +214,7 @@ interface IFrameRefreshRecord {
208
214
  abandoned: boolean;
209
215
  boundaryCandidate?: plugins.smartpuppeteer.ILiveBrowserFrameIdentity;
210
216
  failure?: BrowserRuntimeError;
217
+ producerFailure?: Promise<boolean>;
211
218
  }
212
219
 
213
220
  interface IResourceSlot {
@@ -228,12 +235,15 @@ interface IResourceSlot {
228
235
  proxy?: BrowserEgressProxy;
229
236
  profileDirectory?: string;
230
237
  unsubscribeSession?: () => void;
231
- lease?: ILeaseRecord;
238
+ leases: Map<string, ILeaseRecord>;
232
239
  operation?: IOperationRecord;
233
240
  operationQueue: IQueuedOperationRecord[];
234
241
  operationSchedulerRunning: boolean;
235
- frameSubscription?: IFrameSubscriptionRecord;
242
+ frameSubscriptions: Map<string, IFrameSubscriptionRecord>;
243
+ highestFrameSequence: number;
244
+ producerAcknowledgements: Set<Promise<TFrameAcknowledgementOutcome>>;
236
245
  frameRefresh?: IFrameRefreshRecord;
246
+ frameFailure?: { session: ILiveBrowserSessionLike; promise: Promise<boolean> };
237
247
  idleTimer?: ReturnType<typeof setTimeout>;
238
248
  lifecycleTail: Promise<void>;
239
249
  lifecycleOperationCount: number;
@@ -697,6 +707,10 @@ export class BrowserRuntime {
697
707
  permanentlyFenced: false,
698
708
  retirementPending: false,
699
709
  attachmentFenceCount: 0,
710
+ leases: new Map(),
711
+ frameSubscriptions: new Map(),
712
+ highestFrameSequence: 0,
713
+ producerAcknowledgements: new Set(),
700
714
  operationQueue: [],
701
715
  operationSchedulerRunning: false,
702
716
  lifecycleTail: Promise.resolve(),
@@ -839,7 +853,6 @@ export class BrowserRuntime {
839
853
  const sessionId = requestArg.sessionId === undefined
840
854
  ? undefined
841
855
  : this.validateQualifiedSessionId(requestArg.sessionId);
842
- if (sessionId?.harnessId === 'codex') throw new BrowserRuntimeError('INVALID_INPUT');
843
856
  if (
844
857
  (role === 'agent' && !sessionId)
845
858
  || (role === 'human' && sessionId !== undefined)
@@ -847,7 +860,7 @@ export class BrowserRuntime {
847
860
  || sessionId?.harnessId !== 'flex'))
848
861
  || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
849
862
  || runId !== undefined
850
- || sessionId?.harnessId !== 'opencode'))
863
+ || sessionId === undefined))
851
864
  || (source === 'human' && (scopeId !== undefined || channelId !== undefined
852
865
  || runId !== undefined))
853
866
  ) throw new BrowserRuntimeError('INVALID_INPUT');
@@ -1024,9 +1037,7 @@ export class BrowserRuntime {
1024
1037
  );
1025
1038
  const slot = this.getRegisteredSlot(capability.projectId, capability.browserResourceId);
1026
1039
  this.assertCapabilityStillAttached(capability, slot);
1027
- return capability.role === 'agent'
1028
- ? this.acquireAgentLease(slot, capability, digest, optionsArg)
1029
- : this.acquireHumanLease(slot, capability, digest, optionsArg);
1040
+ return this.acquireParticipantLease(slot, capability, digest, optionsArg);
1030
1041
  }
1031
1042
 
1032
1043
  public authorizeCapability(
@@ -1325,17 +1336,61 @@ export class BrowserRuntime {
1325
1336
  lease: ILeaseRecord,
1326
1337
  viewport: plugins.smartpuppeteer.ILiveBrowserViewport,
1327
1338
  operationOptions: IBrowserRuntimeOperationOptions = {},
1328
- ): Promise<void> {
1339
+ ): Promise<IBrowserRuntimeViewportResult> {
1329
1340
  this.requireHuman(lease);
1330
- await this.runOperation(
1331
- lease,
1332
- 'setViewport',
1333
- 'viewport',
1334
- operationOptions,
1341
+ const preferredViewport = this.validatePreferredViewport(viewport);
1342
+ return this.runOperation(lease, 'setViewport', 'viewport', operationOptions,
1335
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();
1336
1390
  await session.setViewport(viewport, { signal });
1337
- },
1338
- );
1391
+ }
1392
+ const state = session.getState();
1393
+ return { viewport: { ...state.viewport }, viewportRevision: state.viewportRevision };
1339
1394
  }
1340
1395
 
1341
1396
  /** @internal */
@@ -1346,7 +1401,7 @@ export class BrowserRuntime {
1346
1401
  this.requireHuman(lease);
1347
1402
  this.requireValidLease(lease);
1348
1403
  const slot = lease.slot;
1349
- const subscription = slot.frameSubscription;
1404
+ const subscription = slot.frameSubscriptions.get(lease.leaseId);
1350
1405
  if (!subscription || subscription.closed || subscription.lease !== lease) {
1351
1406
  throw new BrowserRuntimeError('BUSY');
1352
1407
  }
@@ -1385,17 +1440,161 @@ export class BrowserRuntime {
1385
1440
  this.requireHuman(lease);
1386
1441
  await this.runOperation(lease, action, 'raw-input', operationOptions, async (_signal, session) => {
1387
1442
  if (action === 'dispatchMouse') {
1388
- 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);
1389
1465
  } else if (action === 'dispatchWheel') {
1390
- 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
+ });
1391
1470
  } else if (action === 'dispatchKey') {
1392
- 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);
1393
1493
  } else {
1394
1494
  await session.insertText(input as plugins.smartpuppeteer.ILiveBrowserInsertTextInput);
1395
1495
  }
1396
1496
  });
1397
1497
  }
1398
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
+
1399
1598
  /** @internal */
1400
1599
  public async subscribeLeaseEvents(
1401
1600
  lease: ILeaseRecord,
@@ -1409,7 +1608,7 @@ export class BrowserRuntime {
1409
1608
  let subscription: IFrameSubscriptionRecord;
1410
1609
  try {
1411
1610
  this.requireValidLease(lease);
1412
- if (slot.frameSubscription || slot.frameRefresh) throw new BrowserRuntimeError('BUSY');
1611
+ if (slot.frameSubscriptions.has(lease.leaseId)) throw new BrowserRuntimeError('BUSY');
1413
1612
  subscription = {
1414
1613
  lease,
1415
1614
  listener,
@@ -1417,7 +1616,7 @@ export class BrowserRuntime {
1417
1616
  highestSequence: 0,
1418
1617
  closed: false,
1419
1618
  };
1420
- slot.frameSubscription = subscription;
1619
+ slot.frameSubscriptions.set(lease.leaseId, subscription);
1421
1620
  this.pushToSubscription(subscription, {
1422
1621
  type: 'state',
1423
1622
  state: this.resourceState(slot.session!.getState()),
@@ -1436,45 +1635,13 @@ export class BrowserRuntime {
1436
1635
  acknowledgementArg: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
1437
1636
  ): Promise<boolean> {
1438
1637
  this.requireHuman(lease);
1439
- const acknowledgement = this.validateFrameAcknowledgement(acknowledgementArg, true);
1440
- const key = this.frameAcknowledgementKey(acknowledgement);
1441
- const slot = lease.slot;
1442
- const subscription = slot.frameSubscription;
1443
1638
  this.requireValidLease(lease);
1444
- if (!subscription || subscription.lease !== lease) return false;
1445
- const outstanding = subscription.outstanding.get(key);
1446
- if (!outstanding) return false;
1447
- const { session, incarnationGeneration } = outstanding;
1448
- const refresh = this.matchingFrameRefresh(
1449
- slot,
1450
- subscription,
1451
- session,
1452
- incarnationGeneration,
1453
- );
1454
- if (refresh?.admissionClosed) {
1455
- subscription.outstanding.delete(key);
1456
- if (outstanding.timer) clearTimeout(outstanding.timer);
1457
- outstanding.timer = undefined;
1458
- return false;
1459
- }
1460
- subscription.outstanding.delete(key);
1461
- if (outstanding.timer) clearTimeout(outstanding.timer);
1462
- outstanding.timer = undefined;
1463
- const refreshOwned = Boolean(refresh && !refresh.abandoned);
1464
- const outcome = await this.beginFrameAcknowledgement(
1465
- session,
1466
- outstanding.acknowledgement,
1467
- 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),
1468
1644
  );
1469
- if (outcome.status === 'fulfilled') return outcome.accepted;
1470
- if (refreshOwned) return false;
1471
- if (refresh && slot.frameRefresh === refresh) {
1472
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
1473
- return false;
1474
- }
1475
- await this.options.beforeFrameFailureTermination?.();
1476
- await this.handleFrameFailure(slot, session, incarnationGeneration, subscription.lease);
1477
- return false;
1478
1645
  }
1479
1646
 
1480
1647
  /** @internal */
@@ -1658,19 +1825,21 @@ export class BrowserRuntime {
1658
1825
  }
1659
1826
  }
1660
1827
 
1661
- private async acquireAgentLease(
1828
+ private async acquireParticipantLease(
1662
1829
  slot: IResourceSlot,
1663
1830
  capability: TCapabilityRecord,
1664
1831
  digest: plugins.Buffer,
1665
1832
  acquisition: TAcquireBrowserRuntimeLeaseOptions,
1666
1833
  ): Promise<BrowserRuntimeLease> {
1667
- const release = slot.mutex.tryAcquire();
1668
- 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;
1669
1838
  try {
1839
+ release = await slot.mutex.acquire();
1670
1840
  this.assertSlotAvailable(slot);
1671
1841
  this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1672
1842
  this.assertCapabilityStillAttached(capability, slot);
1673
- if (slot.lease || slot.operation) throw new BrowserRuntimeError('BUSY');
1674
1843
  await this.ensureSession(slot, acquisition.signal);
1675
1844
  await this.options.beforeLeasePublication?.();
1676
1845
  this.throwIfAcquisitionAborted(acquisition.signal);
@@ -1680,84 +1849,11 @@ export class BrowserRuntime {
1680
1849
  const lease = this.createLeaseRecord(slot, capability, digest);
1681
1850
  return new BrowserRuntimeLease(this, lease);
1682
1851
  } catch (error) {
1683
- if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1852
+ if (slot.session && slot.leases.size === 0) this.scheduleIdleTermination(slot);
1684
1853
  throw error;
1685
1854
  } finally {
1686
- release();
1687
- }
1688
- }
1689
-
1690
- private async acquireHumanLease(
1691
- slot: IResourceSlot,
1692
- capability: TCapabilityRecord,
1693
- digest: plugins.Buffer,
1694
- acquisition: TAcquireBrowserRuntimeLeaseOptions,
1695
- ): Promise<BrowserRuntimeLease> {
1696
- const release = await slot.mutex.acquire();
1697
- let generation = 0;
1698
- let preemptedLease: ILeaseRecord | undefined;
1699
- try {
1700
- this.assertSlotAvailable(slot);
1701
- this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1702
- this.assertCapabilityStillAttached(capability, slot);
1703
- this.throwIfAcquisitionAborted(acquisition.signal);
1704
- if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
1705
- if (slot.lease?.role === 'human') throw new BrowserRuntimeError('BUSY');
1706
- generation = ++slot.arbitrationGeneration;
1707
- slot.fencingGeneration = generation;
1708
- preemptedLease = slot.lease;
1709
- if (preemptedLease) {
1710
- preemptedLease.released = true;
1711
- preemptedLease.controller.abort(new BrowserRuntimeError('ABORTED'));
1712
- slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
1713
- this.notifyFramedLeaseEnded(preemptedLease);
1714
- }
1715
- } finally {
1716
- release();
1717
- }
1718
-
1719
- try {
1720
- if (preemptedLease) await this.quiesceSlot(slot);
1721
- this.throwIfAcquisitionAborted(acquisition.signal);
1722
- const finalRelease = await slot.mutex.acquire();
1723
- try {
1724
- if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
1725
- throw new BrowserRuntimeError('BUSY');
1726
- }
1727
- if (preemptedLease) {
1728
- if (preemptedLease.capability.lease === preemptedLease) {
1729
- preemptedLease.capability.lease = undefined;
1730
- }
1731
- if (slot.lease === preemptedLease) slot.lease = undefined;
1732
- }
1733
- if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
1734
- slot.fencingGeneration = undefined;
1735
- await this.ensureSession(slot, acquisition.signal);
1736
- await this.options.beforeLeasePublication?.();
1737
- this.throwIfAcquisitionAborted(acquisition.signal);
1738
- this.assertSlotAvailable(slot);
1739
- this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1740
- this.assertCapabilityStillAttached(capability, slot);
1741
- const lease = this.createLeaseRecord(slot, capability, digest);
1742
- return new BrowserRuntimeLease(this, lease);
1743
- } finally {
1744
- finalRelease();
1745
- }
1746
- } catch (error) {
1747
- const cleanupRelease = await slot.mutex.acquire();
1748
- try {
1749
- if (preemptedLease) {
1750
- if (preemptedLease.capability.lease === preemptedLease) {
1751
- preemptedLease.capability.lease = undefined;
1752
- }
1753
- if (slot.lease === preemptedLease) slot.lease = undefined;
1754
- }
1755
- if (slot.fencingGeneration === generation) slot.fencingGeneration = undefined;
1756
- if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1757
- } finally {
1758
- cleanupRelease();
1759
- }
1760
- throw error;
1855
+ capability.acquiring = false;
1856
+ release?.();
1761
1857
  }
1762
1858
  }
1763
1859
 
@@ -1768,7 +1864,7 @@ export class BrowserRuntime {
1768
1864
  ): ILeaseRecord {
1769
1865
  this.assertSlotAvailable(slot);
1770
1866
  this.assertCapabilityStillAttached(capability, slot);
1771
- if (capability.lease || slot.lease) throw new BrowserRuntimeError('BUSY');
1867
+ if (capability.lease) throw new BrowserRuntimeError('BUSY');
1772
1868
  if (slot.idleTimer) {
1773
1869
  clearTimeout(slot.idleTimer);
1774
1870
  slot.idleTimer = undefined;
@@ -1788,8 +1884,10 @@ export class BrowserRuntime {
1788
1884
  controller: new AbortController(),
1789
1885
  authorityGeneration: slot.authorityGeneration,
1790
1886
  released: false,
1887
+ heldKeys: new Map(),
1888
+ heldButtons: new Map(),
1791
1889
  };
1792
- slot.lease = lease;
1890
+ slot.leases.set(lease.leaseId, lease);
1793
1891
  capability.lease = lease;
1794
1892
  return lease;
1795
1893
  }
@@ -1954,6 +2052,7 @@ export class BrowserRuntime {
1954
2052
  operationOptions: IBrowserRuntimeOperationOptions,
1955
2053
  execute: (signal: AbortSignal, session: ILiveBrowserSessionLike) => Promise<T>,
1956
2054
  finalize?: () => Promise<void>,
2055
+ participantCleanup = false,
1957
2056
  ): Promise<T> {
1958
2057
  if (!operationOptions || typeof operationOptions !== 'object') {
1959
2058
  throw new BrowserRuntimeError('INVALID_INPUT');
@@ -1968,16 +2067,21 @@ export class BrowserRuntime {
1968
2067
  ) throw new BrowserRuntimeError('INVALID_INPUT');
1969
2068
  if (operationOptions.signal?.aborted) throw new BrowserRuntimeError('ABORTED');
1970
2069
  const slot = lease.slot;
1971
- this.requireValidLease(lease);
2070
+ this.requireQueuedLease(lease, participantCleanup);
1972
2071
  this.assertSlotAvailable(slot);
1973
- 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
+ )) {
1974
2078
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1975
2079
  }
1976
2080
  const timeoutMs = operationOptions.timeoutMs === undefined
1977
2081
  ? this.options.operationTimeoutMs
1978
2082
  : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
1979
2083
  const signal = AbortSignal.any([
1980
- lease.controller.signal,
2084
+ ...(participantCleanup ? [] : [lease.controller.signal]),
1981
2085
  this.lifecycleController.signal,
1982
2086
  operationOptions.signal ?? new AbortController().signal,
1983
2087
  ]);
@@ -1996,6 +2100,7 @@ export class BrowserRuntime {
1996
2100
  resolve: (value) => resolve(value as T),
1997
2101
  reject,
1998
2102
  state: 'queued',
2103
+ participantCleanup,
1999
2104
  };
2000
2105
  const onQueuedAbort = (): void => {
2001
2106
  if (queued.state !== 'queued' && queued.state !== 'starting') return;
@@ -2005,7 +2110,15 @@ export class BrowserRuntime {
2005
2110
  }
2006
2111
  queued.state = 'settled';
2007
2112
  signal.removeEventListener('abort', onQueuedAbort);
2008
- 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
+ }
2009
2122
  };
2010
2123
  queued.onQueuedAbort = onQueuedAbort;
2011
2124
  signal.addEventListener('abort', onQueuedAbort, { once: true });
@@ -2069,7 +2182,7 @@ export class BrowserRuntime {
2069
2182
  try {
2070
2183
  if (queued.state === 'settled') return undefined;
2071
2184
  queued.signal.throwIfAborted();
2072
- this.requireValidLease(lease);
2185
+ this.requireQueuedLease(lease, queued.participantCleanup);
2073
2186
  this.assertSlotAvailable(slot);
2074
2187
  if (slot.operation) throw new BrowserRuntimeError('BUSY');
2075
2188
  session = slot.session!;
@@ -2130,13 +2243,12 @@ export class BrowserRuntime {
2130
2243
  let executionStarted = false;
2131
2244
  let executionSettled = false;
2132
2245
  try {
2133
- await this.runBeforeOperation(operationIdentity, combinedSignal);
2246
+ if (!queued.participantCleanup) await this.runBeforeOperation(operationIdentity, combinedSignal);
2134
2247
  const executionRelease = await slot.mutex.acquire();
2135
2248
  try {
2136
2249
  combinedSignal.throwIfAborted();
2137
- this.requireValidLease(lease);
2250
+ this.requireQueuedLease(lease, queued.participantCleanup);
2138
2251
  this.assertSlotAvailable(slot);
2139
- this.assertCapabilityStillAttached(lease.capability, slot);
2140
2252
  if (
2141
2253
  slot.operation !== operation
2142
2254
  || slot.session !== session
@@ -2144,7 +2256,13 @@ export class BrowserRuntime {
2144
2256
  || slot.incarnationGeneration !== incarnationGeneration
2145
2257
  ) throw new BrowserRuntimeError('ABORTED');
2146
2258
  executionStarted = true;
2147
- 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
+ })();
2148
2266
  operation.promise = executionPromise.then(() => undefined, () => undefined).finally(() => {
2149
2267
  executionSettled = true;
2150
2268
  });
@@ -2162,8 +2280,7 @@ export class BrowserRuntime {
2162
2280
  combinedSignal.removeEventListener('abort', onAbort);
2163
2281
  }
2164
2282
  if (combinedSignal.aborted) throw combinedSignal.reason;
2165
- this.requireValidLease(lease);
2166
- this.assertCapabilityStillAttached(lease.capability, slot);
2283
+ this.requireQueuedLease(lease, queued.participantCleanup);
2167
2284
  if (slot.session !== session! || slot.arbitrationGeneration !== generation!) {
2168
2285
  throw new BrowserRuntimeError('ABORTED');
2169
2286
  }
@@ -2205,6 +2322,12 @@ export class BrowserRuntime {
2205
2322
  failure = error;
2206
2323
  }
2207
2324
  }
2325
+ if (slot.frameFailure?.session === session) {
2326
+ try { await slot.frameFailure.promise; } catch (error) {
2327
+ failed = true;
2328
+ failure = error;
2329
+ }
2330
+ }
2208
2331
  const errorCode = failed ? this.operationErrorCode(failure) : undefined;
2209
2332
  await this.emitAudit(Object.freeze({
2210
2333
  ...operationIdentity,
@@ -2532,10 +2655,10 @@ export class BrowserRuntime {
2532
2655
  ): IFrameRefreshRecord {
2533
2656
  if (
2534
2657
  slot.frameRefresh
2535
- || slot.frameSubscription !== subscription
2658
+ || slot.frameSubscriptions.get(lease.leaseId) !== subscription
2536
2659
  || subscription.closed
2537
2660
  || subscription.lease !== lease
2538
- || slot.lease !== lease
2661
+ || slot.leases.get(lease.leaseId) !== lease
2539
2662
  || slot.session !== session
2540
2663
  ) throw new BrowserRuntimeError('BUSY');
2541
2664
  const state = session.getState();
@@ -2544,7 +2667,7 @@ export class BrowserRuntime {
2544
2667
  !activeTab
2545
2668
  || !activeTab.active
2546
2669
  || activeTab.status !== 'open'
2547
- || !activeTab.streaming
2670
+ || state.status !== 'running'
2548
2671
  ) throw new BrowserRuntimeError('BUSY');
2549
2672
  const refresh: IFrameRefreshRecord = {
2550
2673
  lease,
@@ -2554,7 +2677,7 @@ export class BrowserRuntime {
2554
2677
  initialTabId: activeTab.id,
2555
2678
  initialGeneration: activeTab.generation,
2556
2679
  initialViewportRevision: state.viewportRevision,
2557
- startingHighestSequence: subscription.highestSequence,
2680
+ startingHighestSequence: slot.highestFrameSequence,
2558
2681
  pendingAcknowledgements: new Set(),
2559
2682
  refreshStarted: false,
2560
2683
  admissionClosed: false,
@@ -2604,17 +2727,17 @@ export class BrowserRuntime {
2604
2727
  }
2605
2728
 
2606
2729
  private async drainFrameRefreshOutstanding(refresh: IFrameRefreshRecord): Promise<void> {
2607
- const outstanding = [...refresh.subscription.outstanding.values()];
2608
- refresh.subscription.outstanding.clear();
2609
- for (const frame of outstanding) {
2610
- if (frame.timer) clearTimeout(frame.timer);
2611
- 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');
2612
2740
  }
2613
- await Promise.all(outstanding.map((frame) => this.beginFrameAcknowledgement(
2614
- frame.session,
2615
- frame.acknowledgement,
2616
- refresh,
2617
- )));
2618
2741
  }
2619
2742
 
2620
2743
  private validateFrameRefreshBoundary(
@@ -2646,25 +2769,23 @@ export class BrowserRuntime {
2646
2769
  refresh: IFrameRefreshRecord,
2647
2770
  identity: plugins.smartpuppeteer.ILiveBrowserFrameIdentity,
2648
2771
  ): Promise<void> {
2649
- const retirements: Array<Promise<TFrameAcknowledgementOutcome>> = [];
2650
- for (const [key, frame] of refresh.subscription.outstanding) {
2651
- const acknowledgement = frame.acknowledgement;
2652
- if (
2653
- acknowledgement.tabId === identity.tabId
2654
- && acknowledgement.generation === identity.generation
2655
- && acknowledgement.viewportRevision === identity.viewportRevision
2656
- && acknowledgement.sequence >= identity.sequence
2657
- ) continue;
2658
- refresh.subscription.outstanding.delete(key);
2659
- if (frame.timer) clearTimeout(frame.timer);
2660
- frame.timer = undefined;
2661
- retirements.push(this.beginFrameAcknowledgement(
2662
- frame.session,
2663
- acknowledgement,
2664
- refresh,
2665
- ));
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');
2666
2788
  }
2667
- await Promise.all(retirements);
2668
2789
  }
2669
2790
 
2670
2791
  private beginFrameAcknowledgement(
@@ -2698,14 +2819,14 @@ export class BrowserRuntime {
2698
2819
  refresh: IFrameRefreshRecord,
2699
2820
  ): Promise<void> {
2700
2821
  if (refresh.failure) {
2701
- await this.options.beforeFrameFailureTermination?.();
2702
- const claimed = await this.handleFrameFailure(
2703
- slot,
2704
- refresh.session,
2705
- refresh.incarnationGeneration,
2706
- refresh.lease,
2707
- refresh,
2708
- );
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
+ }
2709
2830
  if (!claimed) {
2710
2831
  this.abandonFrameRefresh(slot, refresh);
2711
2832
  throw new BrowserRuntimeError('ABORTED');
@@ -2720,7 +2841,7 @@ export class BrowserRuntime {
2720
2841
  this.requireValidLease(refresh.lease);
2721
2842
  current = slot.frameRefresh === refresh
2722
2843
  && !refresh.abandoned
2723
- && slot.frameSubscription === refresh.subscription
2844
+ && slot.frameSubscriptions.get(refresh.lease.leaseId) === refresh.subscription
2724
2845
  && !refresh.subscription.closed
2725
2846
  && slot.session === refresh.session
2726
2847
  && slot.incarnationGeneration === refresh.incarnationGeneration;
@@ -2748,7 +2869,7 @@ export class BrowserRuntime {
2748
2869
  refresh.abandoned = true;
2749
2870
  slot.frameRefresh = undefined;
2750
2871
  if (
2751
- slot.frameSubscription === refresh.subscription
2872
+ slot.frameSubscriptions.get(refresh.lease.leaseId) === refresh.subscription
2752
2873
  && !refresh.subscription.closed
2753
2874
  && slot.session === refresh.session
2754
2875
  && slot.incarnationGeneration === refresh.incarnationGeneration
@@ -2766,274 +2887,140 @@ export class BrowserRuntime {
2766
2887
  ): void {
2767
2888
  if (frame.timer || subscription.closed || !subscription.outstanding.has(key)) return;
2768
2889
  const timer = setTimeout(() => {
2769
- // A late application acknowledgement retires the entry exactly like oldest-first
2770
- // eviction: the frame is acknowledged toward the session and the lease is untouched.
2771
- 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));
2772
2892
  }, this.options.frameAcknowledgementTimeoutMs);
2773
2893
  timer.unref();
2774
2894
  frame.timer = timer;
2775
2895
  }
2776
2896
 
2777
- private matchingFrameRefresh(
2778
- slot: IResourceSlot,
2779
- subscription: IFrameSubscriptionRecord,
2780
- session: ILiveBrowserSessionLike,
2781
- incarnationGeneration: number,
2782
- ): IFrameRefreshRecord | undefined {
2783
- const refresh = slot.frameRefresh;
2784
- return refresh
2785
- && !refresh.abandoned
2786
- && refresh.subscription === subscription
2787
- && refresh.lease === subscription.lease
2788
- && refresh.session === session
2789
- && refresh.incarnationGeneration === incarnationGeneration
2790
- ? refresh
2791
- : undefined;
2792
- }
2793
-
2794
2897
  private handleSessionEvent(
2795
2898
  slot: IResourceSlot,
2796
2899
  session: ILiveBrowserSessionLike,
2797
2900
  event: plugins.smartpuppeteer.TLiveBrowserEvent,
2798
2901
  ): void {
2799
- if (slot.session && slot.session !== session) {
2800
- if (event.type === 'frame') {
2801
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, {
2802
- tabId: event.frame.tabId,
2803
- sequence: event.frame.sequence,
2804
- generation: event.frame.generation,
2805
- viewportRevision: event.frame.viewportRevision,
2806
- }));
2807
- }
2808
- return;
2809
- }
2810
- const subscription = slot.frameSubscription;
2811
- const refreshForSession = slot.frameRefresh
2812
- && !slot.frameRefresh.abandoned
2813
- && slot.frameRefresh.session === session
2814
- && slot.frameRefresh.incarnationGeneration === slot.incarnationGeneration
2815
- ? slot.frameRefresh
2816
- : 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;
2817
2906
  if (event.type === 'frame') {
2818
2907
  let acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest;
2819
2908
  try {
2820
- if (!(event.frame.data instanceof Uint8Array)) {
2821
- throw new BrowserRuntimeError('INVALID_INPUT');
2822
- }
2909
+ if (!(event.frame.data instanceof Uint8Array)) throw new BrowserRuntimeError('INVALID_INPUT');
2823
2910
  acknowledgement = this.validateFrameAcknowledgement(event.frame);
2824
- } catch {
2825
- if (refreshForSession) {
2826
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2827
- return;
2911
+ if (acknowledgement.sequence <= slot.highestFrameSequence) {
2912
+ throw new BrowserRuntimeError('FRAME_STREAM_FAILED');
2828
2913
  }
2829
- const failedLease = slot.session === session ? (slot.lease ?? null) : null;
2830
- this.trackCleanup(this.handleFrameFailure(
2831
- slot,
2832
- session,
2833
- slot.incarnationGeneration,
2834
- failedLease,
2835
- ).then(() => undefined));
2836
- return;
2837
- }
2838
- if (!subscription || subscription.closed) {
2839
- if (refreshForSession) {
2840
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2841
- return;
2914
+ if (slot.producerAcknowledgements.size >= this.options.maxOutstandingFrames) {
2915
+ throw new BrowserRuntimeError('FRAME_STREAM_FAILED');
2842
2916
  }
2843
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
2844
- return;
2845
- }
2846
- const refresh = this.matchingFrameRefresh(
2847
- slot,
2848
- subscription,
2849
- session,
2850
- slot.incarnationGeneration,
2851
- );
2852
- if (refreshForSession && refreshForSession !== refresh) {
2853
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2854
- return;
2855
- }
2856
- try {
2857
- this.requireValidLease(subscription.lease);
2858
2917
  } catch {
2859
- if (refresh) {
2860
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2861
- return;
2862
- }
2863
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
2864
- 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));
2865
2920
  return;
2866
2921
  }
2867
- const key = this.frameAcknowledgementKey(acknowledgement);
2868
- if (acknowledgement.sequence <= subscription.highestSequence) {
2869
- 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) {
2870
2932
  this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2871
2933
  return;
2872
2934
  }
2873
- this.trackCleanup(this.handleFrameFailure(
2874
- slot,
2875
- session,
2876
- slot.incarnationGeneration,
2877
- subscription.lease,
2878
- ).then(() => undefined));
2879
- return;
2880
- }
2881
- subscription.highestSequence = acknowledgement.sequence;
2935
+ await this.options.beforeFrameFailureTermination?.();
2936
+ await this.handleFrameFailure(slot, session, incarnationGeneration);
2937
+ }));
2882
2938
  if (event.frame.data.byteLength > this.options.maxFrameBytes) {
2883
- // An oversized frame is dropped, not fatal: the sequence watermark already advanced so
2884
- // later frames stay valid, and the subscriber is told without losing its lease.
2885
- if (refresh) {
2886
- this.failFrameRefresh(refresh, 'FRAME_TOO_LARGE');
2939
+ if (refresh) this.failFrameRefresh(refresh, 'FRAME_TOO_LARGE');
2940
+ for (const subscription of slot.frameSubscriptions.values()) {
2887
2941
  this.pushToSubscription(subscription, {
2888
2942
  type: 'error',
2889
- error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: event.frame.tabId },
2943
+ error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: acknowledgement.tabId },
2890
2944
  });
2891
- return;
2892
2945
  }
2893
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
2894
- this.pushToSubscription(subscription, {
2895
- type: 'error',
2896
- error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: event.frame.tabId },
2897
- });
2898
2946
  return;
2899
2947
  }
2900
- if (refresh?.admissionClosed) return;
2901
- const refreshAdmission = refresh && !refresh.admissionClosed && !refresh.abandoned
2902
- ? refresh
2903
- : undefined;
2904
- if (refreshAdmission) {
2905
- if (refreshAdmission.failure) return;
2948
+ if (refresh?.refreshStarted && !refresh.boundaryCandidate && !refresh.admissionClosed) {
2906
2949
  if (
2907
- subscription.outstanding.size + refreshAdmission.pendingAcknowledgements.size
2908
- >= this.options.maxOutstandingFrames
2909
- ) {
2910
- this.failFrameRefresh(refreshAdmission, 'FRAME_STREAM_FAILED');
2911
- return;
2912
- }
2913
- } else {
2914
- if (refresh && subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2915
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2916
- }
2917
- while (subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2918
- const oldestKey = subscription.outstanding.keys().next().value as string | undefined;
2919
- if (!oldestKey) break;
2920
- this.trackCleanup(
2921
- this.retireOutstandingFrame(subscription, oldestKey, false).then(() => undefined),
2922
- );
2923
- }
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');
2924
2956
  }
2925
- const outstanding: IOutstandingFrameRecord = {
2926
- acknowledgement,
2927
- session,
2928
- incarnationGeneration: slot.incarnationGeneration,
2929
- };
2930
- subscription.outstanding.set(key, outstanding);
2931
- if (!refreshAdmission) this.armFrameAcknowledgementTimer(subscription, key, outstanding);
2932
- const delivered = this.pushToSubscription(subscription, { type: 'frame', frame: event.frame });
2933
- if (
2934
- delivered
2935
- && refreshAdmission
2936
- && refreshAdmission.refreshStarted
2937
- && !refreshAdmission.boundaryCandidate
2938
- ) {
2939
- if (
2940
- acknowledgement.tabId === refreshAdmission.initialTabId
2941
- && acknowledgement.sequence > refreshAdmission.startingHighestSequence
2942
- && acknowledgement.generation > refreshAdmission.initialGeneration
2943
- && acknowledgement.viewportRevision === refreshAdmission.initialViewportRevision
2944
- ) {
2945
- refreshAdmission.boundaryCandidate = { ...acknowledgement };
2946
- } else {
2947
- 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));
2948
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 });
2949
2971
  }
2950
2972
  return;
2951
2973
  }
2952
- if (event.type === 'error') {
2953
- const runtimeEvent = {
2954
- type: 'error',
2955
- error: {
2956
- code: truncateString(event.error.code, 128),
2957
- fatal: event.error.fatal,
2958
- message: truncateString(event.error.message, 2048),
2959
- ...(event.error.tabId ? { tabId: truncateString(event.error.tabId, 128) } : {}),
2960
- },
2961
- } satisfies TBrowserRuntimeEvent;
2962
- let fatalLease = event.error.fatal && slot.lease?.capability.state === 'active'
2963
- ? slot.lease
2964
- : undefined;
2965
- if (fatalLease) {
2966
- try {
2967
- this.requireValidLease(fatalLease);
2968
- } catch {
2969
- fatalLease = undefined;
2970
- }
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'));
2971
2998
  }
2972
- if (event.error.fatal && !fatalLease) return;
2973
- if (fatalLease) {
2974
- const fatalSubscription = subscription
2975
- && !subscription.closed
2976
- && subscription.lease === fatalLease
2977
- ? subscription
2978
- : undefined;
2979
- if (refreshForSession?.lease === fatalLease) {
2980
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2981
- }
2982
- this.invalidateCapabilityRecord(fatalLease.capability);
2983
- try {
2984
- fatalSubscription?.listener(runtimeEvent);
2985
- } catch {
2986
- // Fatal revocation already owns cleanup for a failing listener.
2987
- } finally {
2988
- this.trackCleanup(this.revokeCapabilityRecord(fatalLease.capability));
2989
- }
2990
- 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. */ }
2991
3002
  }
2992
- if (!subscription || subscription.closed) return;
2993
- this.pushToSubscription(subscription, runtimeEvent);
3003
+ this.trackCleanup(failure.then(() => undefined));
2994
3004
  return;
2995
3005
  }
2996
- if (!subscription || subscription.closed) return;
2997
- this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(event.state) });
3006
+ for (const subscription of [...slot.frameSubscriptions.values()]) {
3007
+ this.pushToSubscription(subscription, runtimeEvent);
3008
+ }
2998
3009
  }
2999
3010
 
3000
3011
  private pushToSubscription(
3001
3012
  subscription: IFrameSubscriptionRecord,
3002
3013
  event: TBrowserRuntimeEvent,
3003
3014
  ): boolean {
3015
+ if (subscription.closed) return false;
3004
3016
  try {
3005
3017
  this.requireValidLease(subscription.lease);
3006
3018
  subscription.listener(event);
3007
3019
  return true;
3008
3020
  } catch {
3009
- const slot = subscription.lease.slot;
3010
- const session = slot.session;
3011
- const incarnationGeneration = slot.incarnationGeneration;
3012
- const refresh = session
3013
- ? this.matchingFrameRefresh(slot, subscription, session, incarnationGeneration)
3014
- : undefined;
3015
- if (event.type === 'frame') {
3016
- const key = this.frameAcknowledgementKey(this.validateFrameAcknowledgement(event.frame));
3017
- this.trackCleanup(this.retireOutstandingFrame(subscription, key, true).then(() => undefined));
3018
- return false;
3019
- }
3020
- if (refresh) {
3021
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3022
- return false;
3023
- }
3024
- if (!session) {
3025
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
3026
- return false;
3027
- }
3028
- this.trackCleanup((async () => {
3029
- await this.options.beforeFrameFailureTermination?.();
3030
- await this.handleFrameFailure(
3031
- slot,
3032
- session,
3033
- incarnationGeneration,
3034
- subscription.lease,
3035
- );
3036
- })());
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));
3037
3024
  return false;
3038
3025
  }
3039
3026
  }
@@ -3042,60 +3029,28 @@ export class BrowserRuntime {
3042
3029
  if (subscription.closed) return;
3043
3030
  subscription.closed = true;
3044
3031
  const slot = subscription.lease.slot;
3045
- const refresh = slot.frameRefresh;
3046
- if (refresh?.subscription === subscription && !refresh.abandoned) {
3047
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3032
+ if (slot.frameRefresh?.subscription === subscription) {
3033
+ this.abandonFrameRefresh(slot, slot.frameRefresh);
3034
+ }
3035
+ if (slot.frameSubscriptions.get(subscription.lease.leaseId) === subscription) {
3036
+ slot.frameSubscriptions.delete(subscription.lease.leaseId);
3048
3037
  }
3049
- if (slot.frameSubscription === subscription) slot.frameSubscription = undefined;
3050
- for (const key of [...subscription.outstanding.keys()]) {
3051
- await this.retireOutstandingFrame(subscription, key, false);
3038
+ for (const frame of subscription.outstanding.values()) {
3039
+ if (frame.timer) clearTimeout(frame.timer);
3052
3040
  }
3041
+ subscription.outstanding.clear();
3053
3042
  }
3054
3043
 
3055
3044
  private async retireOutstandingFrame(
3056
3045
  subscription: IFrameSubscriptionRecord,
3057
3046
  key: string,
3058
- failLease: boolean,
3059
3047
  ): Promise<boolean> {
3060
3048
  const outstanding = subscription.outstanding.get(key);
3061
3049
  if (!outstanding) return false;
3062
3050
  subscription.outstanding.delete(key);
3063
3051
  if (outstanding.timer) clearTimeout(outstanding.timer);
3064
- outstanding.timer = undefined;
3065
- const slot = subscription.lease.slot;
3066
- const refresh = this.matchingFrameRefresh(
3067
- slot,
3068
- subscription,
3069
- outstanding.session,
3070
- outstanding.incarnationGeneration,
3071
- );
3072
- if (refresh?.admissionClosed) {
3073
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3074
- return false;
3075
- }
3076
- if (refresh && failLease) this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3077
- const refreshOwned = Boolean(refresh && !refresh.abandoned);
3078
- const outcome = await this.beginFrameAcknowledgement(
3079
- outstanding.session,
3080
- outstanding.acknowledgement,
3081
- refreshOwned ? refresh : undefined,
3082
- );
3083
- if (outcome.status === 'fulfilled' && !failLease) return outcome.accepted;
3084
- if (refreshOwned) return outcome.status === 'fulfilled' ? outcome.accepted : false;
3085
- if (refresh && slot.frameRefresh === refresh) {
3086
- if (outcome.status !== 'fulfilled') {
3087
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3088
- }
3089
- return outcome.status === 'fulfilled' ? outcome.accepted : false;
3090
- }
3091
- await this.options.beforeFrameFailureTermination?.();
3092
- await this.handleFrameFailure(
3093
- slot,
3094
- outstanding.session,
3095
- outstanding.incarnationGeneration,
3096
- subscription.lease,
3097
- );
3098
- return outcome.status === 'fulfilled' ? outcome.accepted : false;
3052
+ const outcome = await outstanding.producerAcknowledgement;
3053
+ return outcome.status === 'fulfilled' && outcome.accepted;
3099
3054
  }
3100
3055
 
3101
3056
  private validateFrameAcknowledgement(
@@ -3157,130 +3112,73 @@ export class BrowserRuntime {
3157
3112
  }
3158
3113
  }
3159
3114
 
3160
- private async acknowledgeFrameInBackground(
3115
+ private async handleFrameFailure(
3161
3116
  slot: IResourceSlot,
3162
3117
  session: ILiveBrowserSessionLike,
3163
- acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
3164
- failLease = false,
3165
- ): Promise<void> {
3166
- const failedLease = slot.session === session ? (slot.lease ?? null) : null;
3167
- const incarnationGeneration = slot.incarnationGeneration;
3168
- const subscription = slot.frameSubscription;
3169
- const refresh = subscription
3170
- ? this.matchingFrameRefresh(slot, subscription, session, incarnationGeneration)
3171
- : undefined;
3172
- if (refresh && failLease) this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3173
- const refreshOwned = Boolean(refresh && !refresh.admissionClosed && !refresh.abandoned);
3174
- const outcome = await this.beginFrameAcknowledgement(
3175
- session,
3176
- acknowledgement,
3177
- refreshOwned ? refresh : undefined,
3178
- );
3179
- if (outcome.status === 'fulfilled' && !failLease) return;
3180
- if (refreshOwned) return;
3181
- if (refresh && slot.frameRefresh === refresh) {
3182
- if (outcome.status !== 'fulfilled') {
3183
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3184
- }
3185
- 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;
3186
3128
  }
3187
- await this.options.beforeFrameFailureTermination?.();
3188
- await this.handleFrameFailure(slot, session, incarnationGeneration, failedLease);
3189
3129
  }
3190
3130
 
3191
- private async handleFrameFailure(
3131
+ private async handleFrameFailureInternal(
3192
3132
  slot: IResourceSlot,
3193
3133
  session: ILiveBrowserSessionLike,
3194
3134
  incarnationGeneration: number,
3195
- expectedLease: ILeaseRecord | null,
3196
3135
  expectedRefresh?: IFrameRefreshRecord,
3197
3136
  ): Promise<boolean> {
3198
- let transitionGeneration = 0;
3199
- let ownsTransitionFence = false;
3200
- const lease = expectedLease ?? undefined;
3201
3137
  const release = await slot.mutex.acquire();
3138
+ let generation: number;
3139
+ let leases: ILeaseRecord[];
3202
3140
  try {
3203
- const exactLeaseIsCurrent = expectedLease === null
3204
- ? slot.lease === undefined
3205
- : slot.lease === expectedLease && expectedLease.capability.lease === expectedLease;
3206
- let leaseAttachmentIsCurrent = true;
3207
- if (lease) {
3208
- try {
3209
- this.assertCapabilityStillAttached(lease.capability, slot);
3210
- } catch {
3211
- leaseAttachmentIsCurrent = false;
3212
- }
3213
- }
3214
- const borrowsLeaseReleaseFence = Boolean(
3215
- lease
3216
- && exactLeaseIsCurrent
3217
- && lease.releaseGeneration !== undefined
3218
- && slot.fencingGeneration === lease.releaseGeneration,
3219
- );
3220
3141
  if (
3221
3142
  slot.session !== session
3222
3143
  || slot.incarnationGeneration !== incarnationGeneration
3223
- || slot.permanentlyFenced
3224
- || slot.retirementPending
3225
- || slot.terminationFenceGeneration !== undefined
3226
- || slot.attachmentFenceCount > 0
3227
- || slot.attachmentRetryBinding
3228
- || !exactLeaseIsCurrent
3229
- || !leaseAttachmentIsCurrent
3230
- || (expectedRefresh !== undefined && slot.frameRefresh !== expectedRefresh)
3231
- || (slot.fencingGeneration !== undefined && !borrowsLeaseReleaseFence)
3144
+ || slot.permanentlyFenced || slot.retirementPending
3145
+ || slot.terminationFenceGeneration !== undefined || slot.fencingGeneration !== undefined
3146
+ || (expectedRefresh && slot.frameRefresh !== expectedRefresh)
3232
3147
  ) return false;
3233
- if (expectedRefresh) {
3234
- expectedRefresh.admissionClosed = true;
3235
- expectedRefresh.abandoned = true;
3236
- slot.frameRefresh = undefined;
3237
- }
3238
- if (lease) this.invalidateCapabilityRecord(lease.capability);
3239
- if (borrowsLeaseReleaseFence) {
3240
- transitionGeneration = slot.fencingGeneration!;
3241
- } else {
3242
- transitionGeneration = ++slot.arbitrationGeneration;
3243
- slot.fencingGeneration = transitionGeneration;
3244
- ownsTransitionFence = true;
3245
- }
3246
- 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);
3247
3154
  lease.released = true;
3248
3155
  lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3156
+ this.notifyFramedLeaseEnded(lease);
3249
3157
  }
3250
- if (!lease || slot.operation?.lease === lease) {
3251
- slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3252
- }
3158
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3253
3159
  } finally {
3254
3160
  release();
3255
3161
  }
3256
- if (lease) this.trackFrameCapabilityRevocation(lease);
3257
3162
  await this.options.afterFrameFailureFence?.();
3258
- if (lease) await this.closeFrameSubscriptionIfLease(lease);
3163
+ await Promise.all(leases.map((lease) => this.closeFrameSubscriptionIfLease(lease)));
3259
3164
  const errors: unknown[] = [];
3260
3165
  await this.quiesceSlot(slot).catch((error) => errors.push(error));
3261
3166
  await this.terminateSlotSession(slot, session).catch((error) => errors.push(error));
3262
3167
  const finalRelease = await slot.mutex.acquire();
3263
3168
  try {
3264
- if (
3265
- slot.arbitrationGeneration === transitionGeneration
3266
- && slot.fencingGeneration === transitionGeneration
3267
- && slot.incarnationGeneration === incarnationGeneration
3268
- && slot.session !== session
3269
- ) {
3270
- if (ownsTransitionFence && lease && slot.lease === lease) slot.lease = undefined;
3271
- if (ownsTransitionFence && lease && lease.capability.lease === lease) {
3272
- 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;
3273
3173
  }
3274
- if (!lease || slot.operation?.lease === lease) slot.operation = undefined;
3275
- if (ownsTransitionFence && errors.length === 0) slot.fencingGeneration = undefined;
3174
+ slot.operation = undefined;
3175
+ slot.fencingGeneration = undefined;
3276
3176
  }
3277
3177
  } finally {
3278
3178
  finalRelease();
3279
3179
  }
3280
- if (errors.length === 1) throw errors[0];
3281
- if (errors.length > 1) {
3282
- throw new AggregateError(errors, 'Frame acknowledgement cleanup is incomplete');
3283
- }
3180
+ for (const lease of leases) this.trackFrameCapabilityRevocation(lease);
3181
+ if (errors.length) throw new AggregateError(errors, 'Frame producer cleanup is incomplete');
3284
3182
  return true;
3285
3183
  }
3286
3184
 
@@ -3293,6 +3191,14 @@ export class BrowserRuntime {
3293
3191
  this.trackCleanup(operation);
3294
3192
  }
3295
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
+
3296
3202
  private requireValidLease(lease: ILeaseRecord): void {
3297
3203
  if (lease.released || lease.controller.signal.aborted) {
3298
3204
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
@@ -3304,7 +3210,7 @@ export class BrowserRuntime {
3304
3210
  if (
3305
3211
  capability !== lease.capability
3306
3212
  || capability.lease !== lease
3307
- || lease.slot.lease !== lease
3213
+ || lease.slot.leases.get(lease.leaseId) !== lease
3308
3214
  || !lease.slot.session
3309
3215
  ) {
3310
3216
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
@@ -3427,57 +3333,58 @@ export class BrowserRuntime {
3427
3333
  }
3428
3334
 
3429
3335
  private async releaseLeaseRecordInternal(lease: ILeaseRecord): Promise<void> {
3430
- if (lease.released && lease.slot.lease !== lease) return;
3431
3336
  const slot = lease.slot;
3337
+ if (slot.leases.get(lease.leaseId) !== lease) return;
3432
3338
  const release = await slot.mutex.acquire();
3433
- const existingFence = slot.fencingGeneration;
3434
- const generation = lease.releaseGeneration
3435
- ?? existingFence
3436
- ?? ++slot.arbitrationGeneration;
3437
- lease.releaseGeneration = generation;
3438
- lease.releaseOwnsFence ??= existingFence === undefined;
3339
+ let operation: IOperationRecord | undefined;
3439
3340
  try {
3440
- if (existingFence !== undefined) {
3441
- lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3442
- } else {
3443
- slot.fencingGeneration = generation;
3444
- }
3445
3341
  lease.released = true;
3446
3342
  lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3447
- if (slot.operation?.lease === lease) {
3448
- slot.operation.controller.abort(new BrowserRuntimeError('ABORTED'));
3449
- }
3343
+ operation = slot.operation?.lease === lease ? slot.operation : undefined;
3344
+ operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
3450
3345
  } finally {
3451
3346
  release();
3452
3347
  }
3453
3348
  await this.closeFrameSubscriptionIfLease(lease);
3454
- await this.quiesceSlot(slot);
3455
- if (slot.frameRefresh?.lease === lease) {
3456
- 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
+ }
3457
3357
  }
3358
+ if (slot.frameRefresh?.lease === lease) this.abandonFrameRefresh(slot, slot.frameRefresh);
3458
3359
  if (
3459
- slot.permanentlyFenced
3460
- && (slot.session || slot.proxy || slot.profileDirectory)
3461
- ) 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
+ }
3462
3374
  const finalRelease = await slot.mutex.acquire();
3463
3375
  try {
3464
3376
  if (lease.capability.state === 'active') this.notifyFramedLeaseEnded(lease);
3465
- if (slot.lease === lease) slot.lease = undefined;
3377
+ slot.leases.delete(lease.leaseId);
3466
3378
  if (lease.capability.lease === lease) lease.capability.lease = undefined;
3467
- if (lease.releaseOwnsFence && slot.fencingGeneration === generation) {
3468
- slot.fencingGeneration = undefined;
3469
- }
3470
- lease.releaseGeneration = undefined;
3471
- lease.releaseOwnsFence = undefined;
3472
- this.scheduleIdleTermination(slot);
3379
+ if (slot.leases.size === 0) this.scheduleIdleTermination(slot);
3473
3380
  } finally {
3474
3381
  finalRelease();
3475
3382
  }
3476
3383
  }
3477
3384
 
3478
3385
  private async closeFrameSubscriptionIfLease(lease: ILeaseRecord): Promise<void> {
3479
- const subscription = lease.slot.frameSubscription;
3480
- if (subscription?.lease === lease) await this.closeFrameSubscription(subscription);
3386
+ const subscription = lease.slot.frameSubscriptions.get(lease.leaseId);
3387
+ if (subscription) await this.closeFrameSubscription(subscription);
3481
3388
  }
3482
3389
 
3483
3390
  private async quiesceSlot(slot: IResourceSlot): Promise<void> {
@@ -3513,6 +3420,12 @@ export class BrowserRuntime {
3513
3420
  return;
3514
3421
  }
3515
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
+ }
3516
3429
  const release = await slot.mutex.acquire();
3517
3430
  try {
3518
3431
  if (
@@ -3565,6 +3478,8 @@ export class BrowserRuntime {
3565
3478
  slot.unsubscribeSession?.();
3566
3479
  slot.unsubscribeSession = undefined;
3567
3480
  slot.session = undefined;
3481
+ slot.highestFrameSequence = 0;
3482
+ slot.producerAcknowledgements.clear();
3568
3483
  }
3569
3484
  if (slot.proxy) {
3570
3485
  await this.stopProxyBounded(slot.proxy).then(
@@ -3582,7 +3497,7 @@ export class BrowserRuntime {
3582
3497
  }
3583
3498
 
3584
3499
  private scheduleIdleTermination(slot: IResourceSlot): void {
3585
- if (slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
3500
+ if (slot.leases.size > 0 || slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
3586
3501
  if (slot.idleTimer) clearTimeout(slot.idleTimer);
3587
3502
  const arbitrationGeneration = slot.arbitrationGeneration;
3588
3503
  const incarnationGeneration = slot.incarnationGeneration;
@@ -3626,7 +3541,7 @@ export class BrowserRuntime {
3626
3541
  ): Promise<void> {
3627
3542
  const release = await slot.mutex.acquire();
3628
3543
  let generation = 0;
3629
- let attachmentFenceOwnsTransition = false;
3544
+ let leases: ILeaseRecord[];
3630
3545
  try {
3631
3546
  if (
3632
3547
  (expected?.arbitrationGeneration !== undefined
@@ -3634,48 +3549,34 @@ export class BrowserRuntime {
3634
3549
  || (expected?.incarnationGeneration !== undefined
3635
3550
  && slot.incarnationGeneration !== expected.incarnationGeneration)
3636
3551
  || (expected?.session !== undefined && slot.session !== expected.session)
3637
- || (expected?.requireIdle && slot.lease)
3552
+ || (expected?.requireIdle && slot.leases.size > 0)
3638
3553
  ) return;
3639
- attachmentFenceOwnsTransition = slot.attachmentRetryBinding !== undefined;
3640
- if (!attachmentFenceOwnsTransition) {
3641
- generation = ++slot.arbitrationGeneration;
3642
- slot.fencingGeneration = generation;
3643
- }
3644
- if (slot.idleTimer) {
3645
- clearTimeout(slot.idleTimer);
3646
- slot.idleTimer = undefined;
3647
- }
3648
- if (slot.lease) {
3649
- slot.lease.released = true;
3650
- slot.lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3651
- if (!stopping && !attachmentFenceOwnsTransition) {
3652
- this.notifyFramedLeaseEnded(slot.lease);
3653
- }
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);
3654
3563
  }
3655
3564
  slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
3656
3565
  } finally {
3657
3566
  release();
3658
3567
  }
3659
- if (attachmentFenceOwnsTransition) {
3660
- await this.quiesceSlot(slot);
3661
- if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
3662
- await this.terminateSlotSession(slot, expected?.session);
3663
- return;
3664
- }
3665
- if (generation === 0) return;
3666
3568
  await this.quiesceSlot(slot);
3667
- if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
3569
+ await Promise.all(leases.map((lease) => this.closeFrameSubscriptionIfLease(lease)));
3668
3570
  await this.terminateSlotSession(slot, expected?.session);
3669
3571
  const finalRelease = await slot.mutex.acquire();
3670
3572
  try {
3671
3573
  if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
3672
3574
  throw new BrowserRuntimeError('BUSY');
3673
3575
  }
3674
- const lease = slot.lease;
3675
- if (lease && lease.capability.lease === lease) {
3676
- 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);
3677
3579
  }
3678
- slot.lease = undefined;
3679
3580
  slot.operation = undefined;
3680
3581
  slot.fencingGeneration = undefined;
3681
3582
  } finally {
@@ -3719,7 +3620,6 @@ export class BrowserRuntime {
3719
3620
  if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
3720
3621
  if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
3721
3622
  if (slot.terminationFenceGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
3722
- if (slot.attachmentFenceCount > 0) throw new BrowserRuntimeError('BUSY');
3723
3623
  if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
3724
3624
  }
3725
3625
 
@@ -3762,54 +3662,45 @@ export class BrowserRuntime {
3762
3662
  if (binding.attachmentRevision < current.attachmentRevision) {
3763
3663
  throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3764
3664
  }
3765
- if (
3766
- binding.attachmentRevision === current.attachmentRevision
3767
- && !slot.attachmentRetryBinding
3768
- ) {
3769
- if (!this.bindingsEqual(binding, current)) {
3770
- throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3771
- }
3665
+ if (binding.attachmentRevision === current.attachmentRevision && !slot.attachmentRetryBinding) {
3666
+ if (!this.bindingsEqual(binding, current)) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3772
3667
  return this.describeResource(slot);
3773
3668
  }
3774
- if (
3775
- slot.attachmentRetryBinding
3776
- && !this.bindingsEqual(binding, slot.attachmentRetryBinding)
3777
- ) throw new BrowserRuntimeError('BUSY');
3669
+ if (slot.attachmentRetryBinding && !this.bindingsEqual(binding, slot.attachmentRetryBinding)) {
3670
+ throw new BrowserRuntimeError('BUSY');
3671
+ }
3778
3672
  const release = await slot.mutex.acquire();
3779
- let fenceGeneration = 0;
3780
3673
  try {
3781
- fenceGeneration = ++slot.arbitrationGeneration;
3782
- slot.fencingGeneration = fenceGeneration;
3783
3674
  slot.attachmentBinding = binding;
3784
3675
  slot.attachmentRetryBinding = binding;
3785
- slot.lease?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3786
- 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
+ }
3787
3684
  } finally {
3788
3685
  release();
3789
3686
  }
3790
3687
  const peerResults = await Promise.allSettled(
3791
3688
  this.framedPeersForResource(slot).map((peer) => peer.close()),
3792
3689
  );
3793
- const capabilities = this.capabilitiesForResource(slot);
3794
3690
  const capabilityResults = await Promise.allSettled(
3795
- capabilities.map((record) => this.revokeCapabilityRecord(record)),
3691
+ this.capabilitiesForResource(slot).filter((record) => record.role === 'agent')
3692
+ .map((record) => this.revokeCapabilityRecord(record)),
3796
3693
  );
3797
3694
  const errors = [...peerResults, ...capabilityResults]
3798
3695
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
3799
3696
  .map((result) => result.reason);
3800
- await this.quiesceSlot(slot).catch((error) => errors.push(error));
3801
3697
  const finalRelease = await slot.mutex.acquire();
3802
3698
  try {
3803
- if (slot.fencingGeneration === fenceGeneration && errors.length === 0) {
3804
- slot.fencingGeneration = undefined;
3805
- slot.attachmentRetryBinding = undefined;
3806
- }
3699
+ if (errors.length === 0) slot.attachmentRetryBinding = undefined;
3807
3700
  } finally {
3808
3701
  finalRelease();
3809
3702
  }
3810
- if (errors.length > 0) {
3811
- throw new AggregateError(errors, 'Attachment fencing is incomplete');
3812
- }
3703
+ if (errors.length) throw new AggregateError(errors, 'Attachment fencing is incomplete');
3813
3704
  return this.describeResource(slot);
3814
3705
  }
3815
3706
 
@@ -3979,6 +3870,9 @@ export class BrowserRuntime {
3979
3870
  || binding.attachmentRevision !== expected.attachmentRevision
3980
3871
  ) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3981
3872
  if (expected.role === 'agent') {
3873
+ if (slot.attachmentFenceCount > 0 || slot.attachmentRetryBinding) {
3874
+ throw new BrowserRuntimeError('BUSY');
3875
+ }
3982
3876
  if (!binding.sessionId || !this.sessionsEqual(binding.sessionId, expected.sessionId)) {
3983
3877
  throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3984
3878
  }
@@ -3995,6 +3889,7 @@ export class BrowserRuntime {
3995
3889
  capability.projectId !== slot.projectId
3996
3890
  || capability.browserResourceId !== slot.browserResourceId
3997
3891
  ) throw new BrowserRuntimeError('CAPABILITY_INVALID');
3892
+ if (capability.role === 'human') return;
3998
3893
  try {
3999
3894
  this.assertCapabilityBinding(slot, capability);
4000
3895
  } catch {
@@ -4027,7 +3922,7 @@ export class BrowserRuntime {
4027
3922
  || sessionId?.harnessId !== 'flex'))
4028
3923
  || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
4029
3924
  || runId !== undefined
4030
- || sessionId?.harnessId !== 'opencode'))
3925
+ || sessionId === undefined))
4031
3926
  || (source === 'human' && (scopeId !== undefined || channelId !== undefined
4032
3927
  || runId !== undefined))
4033
3928
  ) throw new BrowserRuntimeError('INVALID_INPUT');
@@ -4061,7 +3956,8 @@ export class BrowserRuntime {
4061
3956
  runId: runId!,
4062
3957
  };
4063
3958
  }
4064
- if (role === 'agent' && source === 'mcp' && sessionId?.harnessId === 'opencode') {
3959
+ if (role === 'agent' && source === 'mcp'
3960
+ && sessionId !== undefined) {
4065
3961
  return { ...base, role, source, sessionId };
4066
3962
  }
4067
3963
  throw new BrowserRuntimeError('INVALID_INPUT');
@@ -4099,7 +3995,7 @@ export class BrowserRuntime {
4099
3995
  if (
4100
3996
  capability.role === 'agent'
4101
3997
  && capability.source === 'mcp'
4102
- && capability.sessionId?.harnessId === 'opencode'
3998
+ && capability.sessionId !== undefined
4103
3999
  ) {
4104
4000
  return {
4105
4001
  ...base,
@@ -4305,7 +4201,7 @@ export class BrowserRuntimeLease {
4305
4201
  public setViewport(
4306
4202
  viewport: plugins.smartpuppeteer.ILiveBrowserViewport,
4307
4203
  operationOptions?: IBrowserRuntimeOperationOptions,
4308
- ): Promise<void> {
4204
+ ): Promise<IBrowserRuntimeViewportResult> {
4309
4205
  return this.runtime.leaseSetViewport(this.record, viewport, operationOptions);
4310
4206
  }
4311
4207