@modelprofile.com/browser-runtime 4.4.0 → 5.1.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 */
@@ -1482,23 +1649,30 @@ export class BrowserRuntime {
1482
1649
  lease: ILeaseRecord,
1483
1650
  artifactId: string,
1484
1651
  ): Promise<Uint8Array> {
1485
- this.requireHuman(lease);
1486
1652
  this.requireValidLease(lease);
1487
- return this.requireArtifactStore().read(
1653
+ const bytes = await this.requireArtifactStore().read(
1488
1654
  lease.capability.projectId,
1489
1655
  lease.capability.browserResourceId,
1490
1656
  artifactId,
1657
+ lease.role === 'agent' ? lease.leaseId : undefined,
1491
1658
  );
1659
+ try {
1660
+ this.requireValidLease(lease);
1661
+ return bytes;
1662
+ } catch (errorArg) {
1663
+ bytes.fill(0);
1664
+ throw errorArg;
1665
+ }
1492
1666
  }
1493
1667
 
1494
1668
  /** @internal */
1495
1669
  public async deleteLeaseArtifact(lease: ILeaseRecord, artifactId: string): Promise<void> {
1496
- this.requireHuman(lease);
1497
1670
  this.requireValidLease(lease);
1498
1671
  await this.requireArtifactStore().delete(
1499
1672
  lease.capability.projectId,
1500
1673
  lease.capability.browserResourceId,
1501
1674
  artifactId,
1675
+ lease.role === 'agent' ? lease.leaseId : undefined,
1502
1676
  );
1503
1677
  }
1504
1678
 
@@ -1658,19 +1832,21 @@ export class BrowserRuntime {
1658
1832
  }
1659
1833
  }
1660
1834
 
1661
- private async acquireAgentLease(
1835
+ private async acquireParticipantLease(
1662
1836
  slot: IResourceSlot,
1663
1837
  capability: TCapabilityRecord,
1664
1838
  digest: plugins.Buffer,
1665
1839
  acquisition: TAcquireBrowserRuntimeLeaseOptions,
1666
1840
  ): Promise<BrowserRuntimeLease> {
1667
- const release = slot.mutex.tryAcquire();
1668
- if (!release) throw new BrowserRuntimeError('BUSY');
1841
+ this.assertSlotAvailable(slot);
1842
+ if (capability.lease || capability.acquiring) throw new BrowserRuntimeError('BUSY');
1843
+ capability.acquiring = true;
1844
+ let release: (() => void) | undefined;
1669
1845
  try {
1846
+ release = await slot.mutex.acquire();
1670
1847
  this.assertSlotAvailable(slot);
1671
1848
  this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1672
1849
  this.assertCapabilityStillAttached(capability, slot);
1673
- if (slot.lease || slot.operation) throw new BrowserRuntimeError('BUSY');
1674
1850
  await this.ensureSession(slot, acquisition.signal);
1675
1851
  await this.options.beforeLeasePublication?.();
1676
1852
  this.throwIfAcquisitionAborted(acquisition.signal);
@@ -1680,84 +1856,11 @@ export class BrowserRuntime {
1680
1856
  const lease = this.createLeaseRecord(slot, capability, digest);
1681
1857
  return new BrowserRuntimeLease(this, lease);
1682
1858
  } catch (error) {
1683
- if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1859
+ if (slot.session && slot.leases.size === 0) this.scheduleIdleTermination(slot);
1684
1860
  throw error;
1685
1861
  } 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;
1862
+ capability.acquiring = false;
1863
+ release?.();
1761
1864
  }
1762
1865
  }
1763
1866
 
@@ -1768,7 +1871,7 @@ export class BrowserRuntime {
1768
1871
  ): ILeaseRecord {
1769
1872
  this.assertSlotAvailable(slot);
1770
1873
  this.assertCapabilityStillAttached(capability, slot);
1771
- if (capability.lease || slot.lease) throw new BrowserRuntimeError('BUSY');
1874
+ if (capability.lease) throw new BrowserRuntimeError('BUSY');
1772
1875
  if (slot.idleTimer) {
1773
1876
  clearTimeout(slot.idleTimer);
1774
1877
  slot.idleTimer = undefined;
@@ -1788,8 +1891,10 @@ export class BrowserRuntime {
1788
1891
  controller: new AbortController(),
1789
1892
  authorityGeneration: slot.authorityGeneration,
1790
1893
  released: false,
1894
+ heldKeys: new Map(),
1895
+ heldButtons: new Map(),
1791
1896
  };
1792
- slot.lease = lease;
1897
+ slot.leases.set(lease.leaseId, lease);
1793
1898
  capability.lease = lease;
1794
1899
  return lease;
1795
1900
  }
@@ -1954,6 +2059,7 @@ export class BrowserRuntime {
1954
2059
  operationOptions: IBrowserRuntimeOperationOptions,
1955
2060
  execute: (signal: AbortSignal, session: ILiveBrowserSessionLike) => Promise<T>,
1956
2061
  finalize?: () => Promise<void>,
2062
+ participantCleanup = false,
1957
2063
  ): Promise<T> {
1958
2064
  if (!operationOptions || typeof operationOptions !== 'object') {
1959
2065
  throw new BrowserRuntimeError('INVALID_INPUT');
@@ -1968,16 +2074,21 @@ export class BrowserRuntime {
1968
2074
  ) throw new BrowserRuntimeError('INVALID_INPUT');
1969
2075
  if (operationOptions.signal?.aborted) throw new BrowserRuntimeError('ABORTED');
1970
2076
  const slot = lease.slot;
1971
- this.requireValidLease(lease);
2077
+ this.requireQueuedLease(lease, participantCleanup);
1972
2078
  this.assertSlotAvailable(slot);
1973
- if (slot.operationQueue.length >= this.options.maxQueuedOperationsPerLease) {
2079
+ if (!participantCleanup && (
2080
+ slot.operationQueue.filter((entry) => entry.lease === lease).length
2081
+ >= this.options.maxQueuedOperationsPerLease
2082
+ || slot.operationQueue.length >= this.options.maxQueuedOperationsPerLease
2083
+ * this.options.maxCapabilitiesPerResource
2084
+ )) {
1974
2085
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1975
2086
  }
1976
2087
  const timeoutMs = operationOptions.timeoutMs === undefined
1977
2088
  ? this.options.operationTimeoutMs
1978
2089
  : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
1979
2090
  const signal = AbortSignal.any([
1980
- lease.controller.signal,
2091
+ ...(participantCleanup ? [] : [lease.controller.signal]),
1981
2092
  this.lifecycleController.signal,
1982
2093
  operationOptions.signal ?? new AbortController().signal,
1983
2094
  ]);
@@ -1996,6 +2107,7 @@ export class BrowserRuntime {
1996
2107
  resolve: (value) => resolve(value as T),
1997
2108
  reject,
1998
2109
  state: 'queued',
2110
+ participantCleanup,
1999
2111
  };
2000
2112
  const onQueuedAbort = (): void => {
2001
2113
  if (queued.state !== 'queued' && queued.state !== 'starting') return;
@@ -2005,7 +2117,15 @@ export class BrowserRuntime {
2005
2117
  }
2006
2118
  queued.state = 'settled';
2007
2119
  signal.removeEventListener('abort', onQueuedAbort);
2008
- reject(this.queuedOperationAbortError(queued));
2120
+ const error = this.queuedOperationAbortError(queued);
2121
+ const frameFailure = slot.frameFailure;
2122
+ if (frameFailure && frameFailure.session === slot.session) {
2123
+ // Stop admission immediately, but expose the terminal result only
2124
+ // after cleanup of the failed producer's incarnation has settled.
2125
+ void frameFailure.promise.then(() => reject(error), reject);
2126
+ } else {
2127
+ reject(error);
2128
+ }
2009
2129
  };
2010
2130
  queued.onQueuedAbort = onQueuedAbort;
2011
2131
  signal.addEventListener('abort', onQueuedAbort, { once: true });
@@ -2069,7 +2189,7 @@ export class BrowserRuntime {
2069
2189
  try {
2070
2190
  if (queued.state === 'settled') return undefined;
2071
2191
  queued.signal.throwIfAborted();
2072
- this.requireValidLease(lease);
2192
+ this.requireQueuedLease(lease, queued.participantCleanup);
2073
2193
  this.assertSlotAvailable(slot);
2074
2194
  if (slot.operation) throw new BrowserRuntimeError('BUSY');
2075
2195
  session = slot.session!;
@@ -2130,13 +2250,12 @@ export class BrowserRuntime {
2130
2250
  let executionStarted = false;
2131
2251
  let executionSettled = false;
2132
2252
  try {
2133
- await this.runBeforeOperation(operationIdentity, combinedSignal);
2253
+ if (!queued.participantCleanup) await this.runBeforeOperation(operationIdentity, combinedSignal);
2134
2254
  const executionRelease = await slot.mutex.acquire();
2135
2255
  try {
2136
2256
  combinedSignal.throwIfAborted();
2137
- this.requireValidLease(lease);
2257
+ this.requireQueuedLease(lease, queued.participantCleanup);
2138
2258
  this.assertSlotAvailable(slot);
2139
- this.assertCapabilityStillAttached(lease.capability, slot);
2140
2259
  if (
2141
2260
  slot.operation !== operation
2142
2261
  || slot.session !== session
@@ -2144,7 +2263,13 @@ export class BrowserRuntime {
2144
2263
  || slot.incarnationGeneration !== incarnationGeneration
2145
2264
  ) throw new BrowserRuntimeError('ABORTED');
2146
2265
  executionStarted = true;
2147
- executionPromise = queued.execute(combinedSignal, session);
2266
+ executionPromise = (async () => {
2267
+ if (classification === 'navigation' || classification === 'tab') {
2268
+ await this.releaseAllParticipantInput(slot, session);
2269
+ }
2270
+ combinedSignal.throwIfAborted();
2271
+ return queued.execute(combinedSignal, session);
2272
+ })();
2148
2273
  operation.promise = executionPromise.then(() => undefined, () => undefined).finally(() => {
2149
2274
  executionSettled = true;
2150
2275
  });
@@ -2162,8 +2287,7 @@ export class BrowserRuntime {
2162
2287
  combinedSignal.removeEventListener('abort', onAbort);
2163
2288
  }
2164
2289
  if (combinedSignal.aborted) throw combinedSignal.reason;
2165
- this.requireValidLease(lease);
2166
- this.assertCapabilityStillAttached(lease.capability, slot);
2290
+ this.requireQueuedLease(lease, queued.participantCleanup);
2167
2291
  if (slot.session !== session! || slot.arbitrationGeneration !== generation!) {
2168
2292
  throw new BrowserRuntimeError('ABORTED');
2169
2293
  }
@@ -2205,6 +2329,12 @@ export class BrowserRuntime {
2205
2329
  failure = error;
2206
2330
  }
2207
2331
  }
2332
+ if (slot.frameFailure?.session === session) {
2333
+ try { await slot.frameFailure.promise; } catch (error) {
2334
+ failed = true;
2335
+ failure = error;
2336
+ }
2337
+ }
2208
2338
  const errorCode = failed ? this.operationErrorCode(failure) : undefined;
2209
2339
  await this.emitAudit(Object.freeze({
2210
2340
  ...operationIdentity,
@@ -2441,6 +2571,7 @@ export class BrowserRuntime {
2441
2571
  lease.capability.browserResourceId,
2442
2572
  snapshot.mimeType,
2443
2573
  snapshot.data,
2574
+ lease.leaseId,
2444
2575
  );
2445
2576
  return { action: 'screenshot', artifact } satisfies IBrowserScreenshotResult;
2446
2577
  } finally {
@@ -2532,10 +2663,10 @@ export class BrowserRuntime {
2532
2663
  ): IFrameRefreshRecord {
2533
2664
  if (
2534
2665
  slot.frameRefresh
2535
- || slot.frameSubscription !== subscription
2666
+ || slot.frameSubscriptions.get(lease.leaseId) !== subscription
2536
2667
  || subscription.closed
2537
2668
  || subscription.lease !== lease
2538
- || slot.lease !== lease
2669
+ || slot.leases.get(lease.leaseId) !== lease
2539
2670
  || slot.session !== session
2540
2671
  ) throw new BrowserRuntimeError('BUSY');
2541
2672
  const state = session.getState();
@@ -2544,7 +2675,7 @@ export class BrowserRuntime {
2544
2675
  !activeTab
2545
2676
  || !activeTab.active
2546
2677
  || activeTab.status !== 'open'
2547
- || !activeTab.streaming
2678
+ || state.status !== 'running'
2548
2679
  ) throw new BrowserRuntimeError('BUSY');
2549
2680
  const refresh: IFrameRefreshRecord = {
2550
2681
  lease,
@@ -2554,7 +2685,7 @@ export class BrowserRuntime {
2554
2685
  initialTabId: activeTab.id,
2555
2686
  initialGeneration: activeTab.generation,
2556
2687
  initialViewportRevision: state.viewportRevision,
2557
- startingHighestSequence: subscription.highestSequence,
2688
+ startingHighestSequence: slot.highestFrameSequence,
2558
2689
  pendingAcknowledgements: new Set(),
2559
2690
  refreshStarted: false,
2560
2691
  admissionClosed: false,
@@ -2604,17 +2735,17 @@ export class BrowserRuntime {
2604
2735
  }
2605
2736
 
2606
2737
  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;
2738
+ const slot = refresh.lease.slot;
2739
+ for (const subscription of slot.frameSubscriptions.values()) {
2740
+ for (const frame of subscription.outstanding.values()) {
2741
+ if (frame.timer) clearTimeout(frame.timer);
2742
+ }
2743
+ subscription.outstanding.clear();
2744
+ }
2745
+ const outcomes = await Promise.all([...slot.producerAcknowledgements]);
2746
+ if (outcomes.some((outcome) => outcome.status !== 'fulfilled')) {
2747
+ this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2612
2748
  }
2613
- await Promise.all(outstanding.map((frame) => this.beginFrameAcknowledgement(
2614
- frame.session,
2615
- frame.acknowledgement,
2616
- refresh,
2617
- )));
2618
2749
  }
2619
2750
 
2620
2751
  private validateFrameRefreshBoundary(
@@ -2646,25 +2777,23 @@ export class BrowserRuntime {
2646
2777
  refresh: IFrameRefreshRecord,
2647
2778
  identity: plugins.smartpuppeteer.ILiveBrowserFrameIdentity,
2648
2779
  ): 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
- ));
2780
+ for (const subscription of refresh.lease.slot.frameSubscriptions.values()) {
2781
+ for (const [key, frame] of subscription.outstanding) {
2782
+ const acknowledgement = frame.acknowledgement;
2783
+ if (
2784
+ acknowledgement.tabId === identity.tabId
2785
+ && acknowledgement.generation === identity.generation
2786
+ && acknowledgement.viewportRevision === identity.viewportRevision
2787
+ && acknowledgement.sequence >= identity.sequence
2788
+ ) continue;
2789
+ subscription.outstanding.delete(key);
2790
+ if (frame.timer) clearTimeout(frame.timer);
2791
+ }
2792
+ }
2793
+ const outcomes = await Promise.all([...refresh.lease.slot.producerAcknowledgements]);
2794
+ if (outcomes.some((outcome) => outcome.status !== 'fulfilled')) {
2795
+ this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2666
2796
  }
2667
- await Promise.all(retirements);
2668
2797
  }
2669
2798
 
2670
2799
  private beginFrameAcknowledgement(
@@ -2698,14 +2827,14 @@ export class BrowserRuntime {
2698
2827
  refresh: IFrameRefreshRecord,
2699
2828
  ): Promise<void> {
2700
2829
  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
- );
2830
+ let claimed: boolean;
2831
+ if (refresh.producerFailure) claimed = await refresh.producerFailure;
2832
+ else {
2833
+ await this.options.beforeFrameFailureTermination?.();
2834
+ claimed = await this.handleFrameFailure(
2835
+ slot, refresh.session, refresh.incarnationGeneration, refresh,
2836
+ );
2837
+ }
2709
2838
  if (!claimed) {
2710
2839
  this.abandonFrameRefresh(slot, refresh);
2711
2840
  throw new BrowserRuntimeError('ABORTED');
@@ -2720,7 +2849,7 @@ export class BrowserRuntime {
2720
2849
  this.requireValidLease(refresh.lease);
2721
2850
  current = slot.frameRefresh === refresh
2722
2851
  && !refresh.abandoned
2723
- && slot.frameSubscription === refresh.subscription
2852
+ && slot.frameSubscriptions.get(refresh.lease.leaseId) === refresh.subscription
2724
2853
  && !refresh.subscription.closed
2725
2854
  && slot.session === refresh.session
2726
2855
  && slot.incarnationGeneration === refresh.incarnationGeneration;
@@ -2748,7 +2877,7 @@ export class BrowserRuntime {
2748
2877
  refresh.abandoned = true;
2749
2878
  slot.frameRefresh = undefined;
2750
2879
  if (
2751
- slot.frameSubscription === refresh.subscription
2880
+ slot.frameSubscriptions.get(refresh.lease.leaseId) === refresh.subscription
2752
2881
  && !refresh.subscription.closed
2753
2882
  && slot.session === refresh.session
2754
2883
  && slot.incarnationGeneration === refresh.incarnationGeneration
@@ -2766,274 +2895,140 @@ export class BrowserRuntime {
2766
2895
  ): void {
2767
2896
  if (frame.timer || subscription.closed || !subscription.outstanding.has(key)) return;
2768
2897
  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));
2898
+ // Viewer lag retires only its local window; producer acknowledgement already started.
2899
+ this.trackCleanup(this.retireOutstandingFrame(subscription, key).then(() => undefined));
2772
2900
  }, this.options.frameAcknowledgementTimeoutMs);
2773
2901
  timer.unref();
2774
2902
  frame.timer = timer;
2775
2903
  }
2776
2904
 
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
2905
  private handleSessionEvent(
2795
2906
  slot: IResourceSlot,
2796
2907
  session: ILiveBrowserSessionLike,
2797
2908
  event: plugins.smartpuppeteer.TLiveBrowserEvent,
2798
2909
  ): 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;
2910
+ if (slot.session && slot.session !== session) return;
2911
+ const incarnationGeneration = slot.incarnationGeneration;
2912
+ const refresh = slot.frameRefresh?.session === session && !slot.frameRefresh.abandoned
2913
+ ? slot.frameRefresh : undefined;
2817
2914
  if (event.type === 'frame') {
2818
2915
  let acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest;
2819
2916
  try {
2820
- if (!(event.frame.data instanceof Uint8Array)) {
2821
- throw new BrowserRuntimeError('INVALID_INPUT');
2822
- }
2917
+ if (!(event.frame.data instanceof Uint8Array)) throw new BrowserRuntimeError('INVALID_INPUT');
2823
2918
  acknowledgement = this.validateFrameAcknowledgement(event.frame);
2824
- } catch {
2825
- if (refreshForSession) {
2826
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2827
- return;
2919
+ if (acknowledgement.sequence <= slot.highestFrameSequence) {
2920
+ throw new BrowserRuntimeError('FRAME_STREAM_FAILED');
2828
2921
  }
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;
2922
+ if (slot.producerAcknowledgements.size >= this.options.maxOutstandingFrames) {
2923
+ throw new BrowserRuntimeError('FRAME_STREAM_FAILED');
2842
2924
  }
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
2925
  } 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));
2926
+ if (refresh) this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2927
+ else this.trackCleanup(this.handleFrameFailure(slot, session, incarnationGeneration).then(() => undefined));
2865
2928
  return;
2866
2929
  }
2867
- const key = this.frameAcknowledgementKey(acknowledgement);
2868
- if (acknowledgement.sequence <= subscription.highestSequence) {
2869
- if (refresh) {
2930
+ slot.highestFrameSequence = acknowledgement.sequence;
2931
+ // The producer has one bounded ACK, independent of how quickly any viewer paints.
2932
+ const producerAcknowledgement = this.beginFrameAcknowledgement(
2933
+ session, acknowledgement, refresh,
2934
+ );
2935
+ slot.producerAcknowledgements.add(producerAcknowledgement);
2936
+ this.trackCleanup(producerAcknowledgement.then(async (outcome) => {
2937
+ slot.producerAcknowledgements.delete(producerAcknowledgement);
2938
+ if (outcome.status === 'fulfilled') return;
2939
+ if (refresh && slot.frameRefresh === refresh && !refresh.abandoned) {
2870
2940
  this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2871
2941
  return;
2872
2942
  }
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;
2943
+ await this.options.beforeFrameFailureTermination?.();
2944
+ await this.handleFrameFailure(slot, session, incarnationGeneration);
2945
+ }));
2882
2946
  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');
2947
+ if (refresh) this.failFrameRefresh(refresh, 'FRAME_TOO_LARGE');
2948
+ for (const subscription of slot.frameSubscriptions.values()) {
2887
2949
  this.pushToSubscription(subscription, {
2888
2950
  type: 'error',
2889
- error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: event.frame.tabId },
2951
+ error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: acknowledgement.tabId },
2890
2952
  });
2891
- return;
2892
2953
  }
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
2954
  return;
2899
2955
  }
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;
2956
+ if (refresh?.refreshStarted && !refresh.boundaryCandidate && !refresh.admissionClosed) {
2906
2957
  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
- }
2958
+ acknowledgement.tabId === refresh.initialTabId
2959
+ && acknowledgement.sequence > refresh.startingHighestSequence
2960
+ && acknowledgement.generation > refresh.initialGeneration
2961
+ && acknowledgement.viewportRevision === refresh.initialViewportRevision
2962
+ ) refresh.boundaryCandidate = { ...acknowledgement };
2963
+ else this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2924
2964
  }
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');
2965
+ const key = this.frameAcknowledgementKey(acknowledgement);
2966
+ for (const subscription of [...slot.frameSubscriptions.values()]) {
2967
+ if (subscription.closed) continue;
2968
+ while (subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2969
+ const oldestKey = subscription.outstanding.keys().next().value!;
2970
+ this.trackCleanup(this.retireOutstandingFrame(subscription, oldestKey).then(() => undefined));
2948
2971
  }
2972
+ const outstanding: IOutstandingFrameRecord = {
2973
+ acknowledgement, session, incarnationGeneration, producerAcknowledgement,
2974
+ };
2975
+ subscription.highestSequence = acknowledgement.sequence;
2976
+ subscription.outstanding.set(key, outstanding);
2977
+ this.armFrameAcknowledgementTimer(subscription, key, outstanding);
2978
+ this.pushToSubscription(subscription, { type: 'frame', frame: event.frame });
2949
2979
  }
2950
2980
  return;
2951
2981
  }
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
- }
2982
+ const runtimeEvent: TBrowserRuntimeEvent = event.type === 'state'
2983
+ ? { type: 'state', state: this.resourceState(event.state) }
2984
+ : {
2985
+ type: 'error',
2986
+ error: {
2987
+ code: truncateString(event.error.code, 128),
2988
+ fatal: event.error.fatal,
2989
+ message: truncateString(event.error.message, 2048),
2990
+ ...(event.error.tabId ? { tabId: truncateString(event.error.tabId, 128) } : {}),
2991
+ },
2992
+ };
2993
+ if (event.type === 'error' && event.error.fatal) {
2994
+ const subscribers = [...slot.frameSubscriptions.values()].filter((subscription) => (
2995
+ !subscription.closed && !subscription.lease.released
2996
+ ));
2997
+ // Publish the cleanup promise before synchronous revocation wakes queued callers.
2998
+ if (refresh) this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
2999
+ const failure = this.handleFrameFailure(slot, session, incarnationGeneration);
3000
+ if (refresh) refresh.producerFailure = failure;
3001
+ for (const lease of slot.leases.values()) {
3002
+ if (lease.released) continue;
3003
+ this.invalidateCapabilityRecord(lease.capability);
3004
+ lease.released = true;
3005
+ lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
2971
3006
  }
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;
3007
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3008
+ for (const subscription of subscribers) {
3009
+ try { subscription.listener(runtimeEvent); } catch { /* Producer failure owns cleanup. */ }
2991
3010
  }
2992
- if (!subscription || subscription.closed) return;
2993
- this.pushToSubscription(subscription, runtimeEvent);
3011
+ this.trackCleanup(failure.then(() => undefined));
2994
3012
  return;
2995
3013
  }
2996
- if (!subscription || subscription.closed) return;
2997
- this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(event.state) });
3014
+ for (const subscription of [...slot.frameSubscriptions.values()]) {
3015
+ this.pushToSubscription(subscription, runtimeEvent);
3016
+ }
2998
3017
  }
2999
3018
 
3000
3019
  private pushToSubscription(
3001
3020
  subscription: IFrameSubscriptionRecord,
3002
3021
  event: TBrowserRuntimeEvent,
3003
3022
  ): boolean {
3023
+ if (subscription.closed) return false;
3004
3024
  try {
3005
3025
  this.requireValidLease(subscription.lease);
3006
3026
  subscription.listener(event);
3007
3027
  return true;
3008
3028
  } 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
- })());
3029
+ // A failed participant transport cannot revoke another participant or kill the producer.
3030
+ this.trackCleanup(this.closeFrameSubscription(subscription));
3031
+ this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
3037
3032
  return false;
3038
3033
  }
3039
3034
  }
@@ -3042,60 +3037,28 @@ export class BrowserRuntime {
3042
3037
  if (subscription.closed) return;
3043
3038
  subscription.closed = true;
3044
3039
  const slot = subscription.lease.slot;
3045
- const refresh = slot.frameRefresh;
3046
- if (refresh?.subscription === subscription && !refresh.abandoned) {
3047
- this.failFrameRefresh(refresh, 'FRAME_STREAM_FAILED');
3040
+ if (slot.frameRefresh?.subscription === subscription) {
3041
+ this.abandonFrameRefresh(slot, slot.frameRefresh);
3048
3042
  }
3049
- if (slot.frameSubscription === subscription) slot.frameSubscription = undefined;
3050
- for (const key of [...subscription.outstanding.keys()]) {
3051
- await this.retireOutstandingFrame(subscription, key, false);
3043
+ if (slot.frameSubscriptions.get(subscription.lease.leaseId) === subscription) {
3044
+ slot.frameSubscriptions.delete(subscription.lease.leaseId);
3045
+ }
3046
+ for (const frame of subscription.outstanding.values()) {
3047
+ if (frame.timer) clearTimeout(frame.timer);
3052
3048
  }
3049
+ subscription.outstanding.clear();
3053
3050
  }
3054
3051
 
3055
3052
  private async retireOutstandingFrame(
3056
3053
  subscription: IFrameSubscriptionRecord,
3057
3054
  key: string,
3058
- failLease: boolean,
3059
3055
  ): Promise<boolean> {
3060
3056
  const outstanding = subscription.outstanding.get(key);
3061
3057
  if (!outstanding) return false;
3062
3058
  subscription.outstanding.delete(key);
3063
3059
  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;
3060
+ const outcome = await outstanding.producerAcknowledgement;
3061
+ return outcome.status === 'fulfilled' && outcome.accepted;
3099
3062
  }
3100
3063
 
3101
3064
  private validateFrameAcknowledgement(
@@ -3157,130 +3120,73 @@ export class BrowserRuntime {
3157
3120
  }
3158
3121
  }
3159
3122
 
3160
- private async acknowledgeFrameInBackground(
3123
+ private async handleFrameFailure(
3161
3124
  slot: IResourceSlot,
3162
3125
  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;
3126
+ incarnationGeneration: number,
3127
+ expectedRefresh?: IFrameRefreshRecord,
3128
+ ): Promise<boolean> {
3129
+ if (slot.session !== session || slot.incarnationGeneration !== incarnationGeneration) return false;
3130
+ if (slot.frameFailure?.session === session) return slot.frameFailure.promise;
3131
+ const promise = this.handleFrameFailureInternal(slot, session, incarnationGeneration, expectedRefresh);
3132
+ const failure = { session, promise };
3133
+ slot.frameFailure = failure;
3134
+ try { return await promise; } finally {
3135
+ if (slot.frameFailure === failure) slot.frameFailure = undefined;
3186
3136
  }
3187
- await this.options.beforeFrameFailureTermination?.();
3188
- await this.handleFrameFailure(slot, session, incarnationGeneration, failedLease);
3189
3137
  }
3190
3138
 
3191
- private async handleFrameFailure(
3139
+ private async handleFrameFailureInternal(
3192
3140
  slot: IResourceSlot,
3193
3141
  session: ILiveBrowserSessionLike,
3194
3142
  incarnationGeneration: number,
3195
- expectedLease: ILeaseRecord | null,
3196
3143
  expectedRefresh?: IFrameRefreshRecord,
3197
3144
  ): Promise<boolean> {
3198
- let transitionGeneration = 0;
3199
- let ownsTransitionFence = false;
3200
- const lease = expectedLease ?? undefined;
3201
3145
  const release = await slot.mutex.acquire();
3146
+ let generation: number;
3147
+ let leases: ILeaseRecord[];
3202
3148
  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
3149
  if (
3221
3150
  slot.session !== session
3222
3151
  || 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)
3152
+ || slot.permanentlyFenced || slot.retirementPending
3153
+ || slot.terminationFenceGeneration !== undefined || slot.fencingGeneration !== undefined
3154
+ || (expectedRefresh && slot.frameRefresh !== expectedRefresh)
3232
3155
  ) 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) {
3156
+ generation = ++slot.arbitrationGeneration;
3157
+ slot.fencingGeneration = generation;
3158
+ this.abandonFrameRefresh(slot);
3159
+ leases = [...slot.leases.values()];
3160
+ for (const lease of leases) {
3161
+ this.invalidateCapabilityRecord(lease.capability);
3247
3162
  lease.released = true;
3248
3163
  lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3164
+ this.notifyFramedLeaseEnded(lease);
3249
3165
  }
3250
- if (!lease || slot.operation?.lease === lease) {
3251
- slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3252
- }
3166
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3253
3167
  } finally {
3254
3168
  release();
3255
3169
  }
3256
- if (lease) this.trackFrameCapabilityRevocation(lease);
3257
3170
  await this.options.afterFrameFailureFence?.();
3258
- if (lease) await this.closeFrameSubscriptionIfLease(lease);
3171
+ await Promise.all(leases.map((lease) => this.closeFrameSubscriptionIfLease(lease)));
3259
3172
  const errors: unknown[] = [];
3260
3173
  await this.quiesceSlot(slot).catch((error) => errors.push(error));
3261
3174
  await this.terminateSlotSession(slot, session).catch((error) => errors.push(error));
3262
3175
  const finalRelease = await slot.mutex.acquire();
3263
3176
  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;
3177
+ if (slot.fencingGeneration === generation && errors.length === 0) {
3178
+ for (const lease of leases) {
3179
+ slot.leases.delete(lease.leaseId);
3180
+ if (lease.capability.lease === lease) lease.capability.lease = undefined;
3273
3181
  }
3274
- if (!lease || slot.operation?.lease === lease) slot.operation = undefined;
3275
- if (ownsTransitionFence && errors.length === 0) slot.fencingGeneration = undefined;
3182
+ slot.operation = undefined;
3183
+ slot.fencingGeneration = undefined;
3276
3184
  }
3277
3185
  } finally {
3278
3186
  finalRelease();
3279
3187
  }
3280
- if (errors.length === 1) throw errors[0];
3281
- if (errors.length > 1) {
3282
- throw new AggregateError(errors, 'Frame acknowledgement cleanup is incomplete');
3283
- }
3188
+ for (const lease of leases) this.trackFrameCapabilityRevocation(lease);
3189
+ if (errors.length) throw new AggregateError(errors, 'Frame producer cleanup is incomplete');
3284
3190
  return true;
3285
3191
  }
3286
3192
 
@@ -3293,6 +3199,14 @@ export class BrowserRuntime {
3293
3199
  this.trackCleanup(operation);
3294
3200
  }
3295
3201
 
3202
+ private requireQueuedLease(lease: ILeaseRecord, participantCleanup = false): void {
3203
+ if (!participantCleanup) return this.requireValidLease(lease);
3204
+ if (
3205
+ !lease.released || lease.slot.leases.get(lease.leaseId) !== lease
3206
+ || lease.capability.lease !== lease || !lease.slot.session
3207
+ ) throw new BrowserRuntimeError('CAPABILITY_REVOKED');
3208
+ }
3209
+
3296
3210
  private requireValidLease(lease: ILeaseRecord): void {
3297
3211
  if (lease.released || lease.controller.signal.aborted) {
3298
3212
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
@@ -3304,7 +3218,7 @@ export class BrowserRuntime {
3304
3218
  if (
3305
3219
  capability !== lease.capability
3306
3220
  || capability.lease !== lease
3307
- || lease.slot.lease !== lease
3221
+ || lease.slot.leases.get(lease.leaseId) !== lease
3308
3222
  || !lease.slot.session
3309
3223
  ) {
3310
3224
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
@@ -3427,57 +3341,58 @@ export class BrowserRuntime {
3427
3341
  }
3428
3342
 
3429
3343
  private async releaseLeaseRecordInternal(lease: ILeaseRecord): Promise<void> {
3430
- if (lease.released && lease.slot.lease !== lease) return;
3431
3344
  const slot = lease.slot;
3345
+ if (slot.leases.get(lease.leaseId) !== lease) return;
3432
3346
  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;
3347
+ let operation: IOperationRecord | undefined;
3439
3348
  try {
3440
- if (existingFence !== undefined) {
3441
- lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3442
- } else {
3443
- slot.fencingGeneration = generation;
3444
- }
3445
3349
  lease.released = true;
3446
3350
  lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3447
- if (slot.operation?.lease === lease) {
3448
- slot.operation.controller.abort(new BrowserRuntimeError('ABORTED'));
3449
- }
3351
+ operation = slot.operation?.lease === lease ? slot.operation : undefined;
3352
+ operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
3450
3353
  } finally {
3451
3354
  release();
3452
3355
  }
3453
3356
  await this.closeFrameSubscriptionIfLease(lease);
3454
- await this.quiesceSlot(slot);
3455
- if (slot.frameRefresh?.lease === lease) {
3456
- this.abandonFrameRefresh(slot, slot.frameRefresh);
3357
+ if (operation) {
3358
+ const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
3359
+ if (!settled.settled) {
3360
+ // An unquiescent operation belongs to the browser process, so its incarnation must end.
3361
+ await this.terminateRegisteredResourceInternal(slot, false, {
3362
+ session: operation.session, incarnationGeneration: operation.incarnationGeneration,
3363
+ });
3364
+ }
3457
3365
  }
3366
+ if (slot.frameRefresh?.lease === lease) this.abandonFrameRefresh(slot, slot.frameRefresh);
3458
3367
  if (
3459
- slot.permanentlyFenced
3460
- && (slot.session || slot.proxy || slot.profileDirectory)
3461
- ) await this.terminateSlotSession(slot);
3368
+ this.lifecycleState === 'running' && slot.session && !slot.permanentlyFenced && !slot.retirementPending
3369
+ && slot.fencingGeneration === undefined && slot.terminationFenceGeneration === undefined
3370
+ && (lease.preferredViewport || lease.heldKeys.size || lease.heldButtons.size)
3371
+ ) {
3372
+ await this.runOperation(lease, 'releaseParticipant', 'viewport', {},
3373
+ async (signal, session) => {
3374
+ await this.releaseParticipantInput(lease, session);
3375
+ await this.applySharedViewport(slot, session, signal);
3376
+ lease.preferredViewport = undefined;
3377
+ }, undefined, true);
3378
+ }
3379
+ if (slot.permanentlyFenced && (slot.session || slot.proxy || slot.profileDirectory)) {
3380
+ await this.terminateSlotSession(slot);
3381
+ }
3462
3382
  const finalRelease = await slot.mutex.acquire();
3463
3383
  try {
3464
3384
  if (lease.capability.state === 'active') this.notifyFramedLeaseEnded(lease);
3465
- if (slot.lease === lease) slot.lease = undefined;
3385
+ slot.leases.delete(lease.leaseId);
3466
3386
  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);
3387
+ if (slot.leases.size === 0) this.scheduleIdleTermination(slot);
3473
3388
  } finally {
3474
3389
  finalRelease();
3475
3390
  }
3476
3391
  }
3477
3392
 
3478
3393
  private async closeFrameSubscriptionIfLease(lease: ILeaseRecord): Promise<void> {
3479
- const subscription = lease.slot.frameSubscription;
3480
- if (subscription?.lease === lease) await this.closeFrameSubscription(subscription);
3394
+ const subscription = lease.slot.frameSubscriptions.get(lease.leaseId);
3395
+ if (subscription) await this.closeFrameSubscription(subscription);
3481
3396
  }
3482
3397
 
3483
3398
  private async quiesceSlot(slot: IResourceSlot): Promise<void> {
@@ -3513,6 +3428,12 @@ export class BrowserRuntime {
3513
3428
  return;
3514
3429
  }
3515
3430
  const slot = operation.lease.slot;
3431
+ if (slot.operation === operation && slot.session === operation.session
3432
+ && slot.incarnationGeneration === operation.incarnationGeneration) {
3433
+ await this.terminateRegisteredResourceInternal(slot, false, {
3434
+ session: operation.session, incarnationGeneration: operation.incarnationGeneration,
3435
+ });
3436
+ }
3516
3437
  const release = await slot.mutex.acquire();
3517
3438
  try {
3518
3439
  if (
@@ -3565,6 +3486,8 @@ export class BrowserRuntime {
3565
3486
  slot.unsubscribeSession?.();
3566
3487
  slot.unsubscribeSession = undefined;
3567
3488
  slot.session = undefined;
3489
+ slot.highestFrameSequence = 0;
3490
+ slot.producerAcknowledgements.clear();
3568
3491
  }
3569
3492
  if (slot.proxy) {
3570
3493
  await this.stopProxyBounded(slot.proxy).then(
@@ -3582,7 +3505,7 @@ export class BrowserRuntime {
3582
3505
  }
3583
3506
 
3584
3507
  private scheduleIdleTermination(slot: IResourceSlot): void {
3585
- if (slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
3508
+ if (slot.leases.size > 0 || slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
3586
3509
  if (slot.idleTimer) clearTimeout(slot.idleTimer);
3587
3510
  const arbitrationGeneration = slot.arbitrationGeneration;
3588
3511
  const incarnationGeneration = slot.incarnationGeneration;
@@ -3626,7 +3549,7 @@ export class BrowserRuntime {
3626
3549
  ): Promise<void> {
3627
3550
  const release = await slot.mutex.acquire();
3628
3551
  let generation = 0;
3629
- let attachmentFenceOwnsTransition = false;
3552
+ let leases: ILeaseRecord[];
3630
3553
  try {
3631
3554
  if (
3632
3555
  (expected?.arbitrationGeneration !== undefined
@@ -3634,48 +3557,34 @@ export class BrowserRuntime {
3634
3557
  || (expected?.incarnationGeneration !== undefined
3635
3558
  && slot.incarnationGeneration !== expected.incarnationGeneration)
3636
3559
  || (expected?.session !== undefined && slot.session !== expected.session)
3637
- || (expected?.requireIdle && slot.lease)
3560
+ || (expected?.requireIdle && slot.leases.size > 0)
3638
3561
  ) 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
- }
3562
+ generation = ++slot.arbitrationGeneration;
3563
+ slot.fencingGeneration = generation;
3564
+ if (slot.idleTimer) clearTimeout(slot.idleTimer);
3565
+ slot.idleTimer = undefined;
3566
+ leases = [...slot.leases.values()];
3567
+ for (const lease of leases) {
3568
+ lease.released = true;
3569
+ lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3570
+ if (!stopping) this.notifyFramedLeaseEnded(lease);
3654
3571
  }
3655
3572
  slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
3656
3573
  } finally {
3657
3574
  release();
3658
3575
  }
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
3576
  await this.quiesceSlot(slot);
3667
- if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
3577
+ await Promise.all(leases.map((lease) => this.closeFrameSubscriptionIfLease(lease)));
3668
3578
  await this.terminateSlotSession(slot, expected?.session);
3669
3579
  const finalRelease = await slot.mutex.acquire();
3670
3580
  try {
3671
3581
  if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
3672
3582
  throw new BrowserRuntimeError('BUSY');
3673
3583
  }
3674
- const lease = slot.lease;
3675
- if (lease && lease.capability.lease === lease) {
3676
- lease.capability.lease = undefined;
3584
+ for (const lease of leases) {
3585
+ if (lease.capability.lease === lease) lease.capability.lease = undefined;
3586
+ slot.leases.delete(lease.leaseId);
3677
3587
  }
3678
- slot.lease = undefined;
3679
3588
  slot.operation = undefined;
3680
3589
  slot.fencingGeneration = undefined;
3681
3590
  } finally {
@@ -3719,7 +3628,6 @@ export class BrowserRuntime {
3719
3628
  if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
3720
3629
  if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
3721
3630
  if (slot.terminationFenceGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
3722
- if (slot.attachmentFenceCount > 0) throw new BrowserRuntimeError('BUSY');
3723
3631
  if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
3724
3632
  }
3725
3633
 
@@ -3762,54 +3670,45 @@ export class BrowserRuntime {
3762
3670
  if (binding.attachmentRevision < current.attachmentRevision) {
3763
3671
  throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3764
3672
  }
3765
- if (
3766
- binding.attachmentRevision === current.attachmentRevision
3767
- && !slot.attachmentRetryBinding
3768
- ) {
3769
- if (!this.bindingsEqual(binding, current)) {
3770
- throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3771
- }
3673
+ if (binding.attachmentRevision === current.attachmentRevision && !slot.attachmentRetryBinding) {
3674
+ if (!this.bindingsEqual(binding, current)) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3772
3675
  return this.describeResource(slot);
3773
3676
  }
3774
- if (
3775
- slot.attachmentRetryBinding
3776
- && !this.bindingsEqual(binding, slot.attachmentRetryBinding)
3777
- ) throw new BrowserRuntimeError('BUSY');
3677
+ if (slot.attachmentRetryBinding && !this.bindingsEqual(binding, slot.attachmentRetryBinding)) {
3678
+ throw new BrowserRuntimeError('BUSY');
3679
+ }
3778
3680
  const release = await slot.mutex.acquire();
3779
- let fenceGeneration = 0;
3780
3681
  try {
3781
- fenceGeneration = ++slot.arbitrationGeneration;
3782
- slot.fencingGeneration = fenceGeneration;
3783
3682
  slot.attachmentBinding = binding;
3784
3683
  slot.attachmentRetryBinding = binding;
3785
- slot.lease?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3786
- slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3684
+ for (const lease of slot.leases.values()) {
3685
+ if (lease.role === 'agent') {
3686
+ lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3687
+ }
3688
+ }
3689
+ if (slot.operation?.lease.role === 'agent') {
3690
+ slot.operation.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3691
+ }
3787
3692
  } finally {
3788
3693
  release();
3789
3694
  }
3790
3695
  const peerResults = await Promise.allSettled(
3791
3696
  this.framedPeersForResource(slot).map((peer) => peer.close()),
3792
3697
  );
3793
- const capabilities = this.capabilitiesForResource(slot);
3794
3698
  const capabilityResults = await Promise.allSettled(
3795
- capabilities.map((record) => this.revokeCapabilityRecord(record)),
3699
+ this.capabilitiesForResource(slot).filter((record) => record.role === 'agent')
3700
+ .map((record) => this.revokeCapabilityRecord(record)),
3796
3701
  );
3797
3702
  const errors = [...peerResults, ...capabilityResults]
3798
3703
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
3799
3704
  .map((result) => result.reason);
3800
- await this.quiesceSlot(slot).catch((error) => errors.push(error));
3801
3705
  const finalRelease = await slot.mutex.acquire();
3802
3706
  try {
3803
- if (slot.fencingGeneration === fenceGeneration && errors.length === 0) {
3804
- slot.fencingGeneration = undefined;
3805
- slot.attachmentRetryBinding = undefined;
3806
- }
3707
+ if (errors.length === 0) slot.attachmentRetryBinding = undefined;
3807
3708
  } finally {
3808
3709
  finalRelease();
3809
3710
  }
3810
- if (errors.length > 0) {
3811
- throw new AggregateError(errors, 'Attachment fencing is incomplete');
3812
- }
3711
+ if (errors.length) throw new AggregateError(errors, 'Attachment fencing is incomplete');
3813
3712
  return this.describeResource(slot);
3814
3713
  }
3815
3714
 
@@ -3979,6 +3878,9 @@ export class BrowserRuntime {
3979
3878
  || binding.attachmentRevision !== expected.attachmentRevision
3980
3879
  ) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3981
3880
  if (expected.role === 'agent') {
3881
+ if (slot.attachmentFenceCount > 0 || slot.attachmentRetryBinding) {
3882
+ throw new BrowserRuntimeError('BUSY');
3883
+ }
3982
3884
  if (!binding.sessionId || !this.sessionsEqual(binding.sessionId, expected.sessionId)) {
3983
3885
  throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
3984
3886
  }
@@ -3995,6 +3897,7 @@ export class BrowserRuntime {
3995
3897
  capability.projectId !== slot.projectId
3996
3898
  || capability.browserResourceId !== slot.browserResourceId
3997
3899
  ) throw new BrowserRuntimeError('CAPABILITY_INVALID');
3900
+ if (capability.role === 'human') return;
3998
3901
  try {
3999
3902
  this.assertCapabilityBinding(slot, capability);
4000
3903
  } catch {
@@ -4027,7 +3930,7 @@ export class BrowserRuntime {
4027
3930
  || sessionId?.harnessId !== 'flex'))
4028
3931
  || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
4029
3932
  || runId !== undefined
4030
- || sessionId?.harnessId !== 'opencode'))
3933
+ || sessionId === undefined))
4031
3934
  || (source === 'human' && (scopeId !== undefined || channelId !== undefined
4032
3935
  || runId !== undefined))
4033
3936
  ) throw new BrowserRuntimeError('INVALID_INPUT');
@@ -4061,7 +3964,8 @@ export class BrowserRuntime {
4061
3964
  runId: runId!,
4062
3965
  };
4063
3966
  }
4064
- if (role === 'agent' && source === 'mcp' && sessionId?.harnessId === 'opencode') {
3967
+ if (role === 'agent' && source === 'mcp'
3968
+ && sessionId !== undefined) {
4065
3969
  return { ...base, role, source, sessionId };
4066
3970
  }
4067
3971
  throw new BrowserRuntimeError('INVALID_INPUT');
@@ -4099,7 +4003,7 @@ export class BrowserRuntime {
4099
4003
  if (
4100
4004
  capability.role === 'agent'
4101
4005
  && capability.source === 'mcp'
4102
- && capability.sessionId?.harnessId === 'opencode'
4006
+ && capability.sessionId !== undefined
4103
4007
  ) {
4104
4008
  return {
4105
4009
  ...base,
@@ -4305,7 +4209,7 @@ export class BrowserRuntimeLease {
4305
4209
  public setViewport(
4306
4210
  viewport: plugins.smartpuppeteer.ILiveBrowserViewport,
4307
4211
  operationOptions?: IBrowserRuntimeOperationOptions,
4308
- ): Promise<void> {
4212
+ ): Promise<IBrowserRuntimeViewportResult> {
4309
4213
  return this.runtime.leaseSetViewport(this.record, viewport, operationOptions);
4310
4214
  }
4311
4215