camofox-browser 2.4.6 → 2.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +28 -4
  3. package/THIRD-PARTY-NOTICES.md +49 -0
  4. package/dist/src/cli/server/manager.d.ts.map +1 -1
  5. package/dist/src/cli/server/manager.js +1 -4
  6. package/dist/src/cli/server/manager.js.map +1 -1
  7. package/dist/src/routes/core.d.ts.map +1 -1
  8. package/dist/src/routes/core.js +46 -1
  9. package/dist/src/routes/core.js.map +1 -1
  10. package/dist/src/routes/openclaw.d.ts.map +1 -1
  11. package/dist/src/routes/openclaw.js +35 -1
  12. package/dist/src/routes/openclaw.js.map +1 -1
  13. package/dist/src/services/context-pool.d.ts +42 -2
  14. package/dist/src/services/context-pool.d.ts.map +1 -1
  15. package/dist/src/services/context-pool.js +408 -115
  16. package/dist/src/services/context-pool.js.map +1 -1
  17. package/dist/src/services/health.d.ts +57 -6
  18. package/dist/src/services/health.d.ts.map +1 -1
  19. package/dist/src/services/health.js +132 -11
  20. package/dist/src/services/health.js.map +1 -1
  21. package/dist/src/services/nav-recovery.d.ts +23 -0
  22. package/dist/src/services/nav-recovery.d.ts.map +1 -0
  23. package/dist/src/services/nav-recovery.js +63 -0
  24. package/dist/src/services/nav-recovery.js.map +1 -0
  25. package/dist/src/services/session.d.ts +84 -0
  26. package/dist/src/services/session.d.ts.map +1 -1
  27. package/dist/src/services/session.js +338 -35
  28. package/dist/src/services/session.js.map +1 -1
  29. package/dist/src/services/tab.d.ts +5 -1
  30. package/dist/src/services/tab.d.ts.map +1 -1
  31. package/dist/src/services/tab.js +114 -51
  32. package/dist/src/services/tab.js.map +1 -1
  33. package/dist/src/services/tracing.js +2 -2
  34. package/dist/src/services/tracing.js.map +1 -1
  35. package/dist/src/services/vnc.d.ts.map +1 -1
  36. package/dist/src/services/vnc.js +2 -0
  37. package/dist/src/services/vnc.js.map +1 -1
  38. package/dist/src/types.d.ts +2 -0
  39. package/dist/src/types.d.ts.map +1 -1
  40. package/dist/src/utils/config.d.ts +3 -0
  41. package/dist/src/utils/config.d.ts.map +1 -1
  42. package/dist/src/utils/config.js +3 -0
  43. package/dist/src/utils/config.js.map +1 -1
  44. package/dist/src/utils/launcher.js +1 -1
  45. package/dist/src/utils/launcher.js.map +1 -1
  46. package/dist/src/utils/platform-support.d.ts +17 -0
  47. package/dist/src/utils/platform-support.d.ts.map +1 -0
  48. package/dist/src/utils/platform-support.js +57 -0
  49. package/dist/src/utils/platform-support.js.map +1 -0
  50. package/dist/src/utils/profile-path.d.ts +9 -0
  51. package/dist/src/utils/profile-path.d.ts.map +1 -0
  52. package/dist/src/utils/profile-path.js +105 -0
  53. package/dist/src/utils/profile-path.js.map +1 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +10 -3
  57. package/dist/src/cli/utils/api-fallback.d.ts +0 -4
  58. package/dist/src/cli/utils/api-fallback.d.ts.map +0 -1
  59. package/dist/src/cli/utils/api-fallback.js +0 -20
  60. package/dist/src/cli/utils/api-fallback.js.map +0 -1
@@ -3,11 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.contextPool = exports.ContextPool = void 0;
6
+ exports.contextPool = exports.ContextPool = exports.spawnXvfbForTests = void 0;
7
7
  exports.getDisplayForUser = getDisplayForUser;
8
- const node_os_1 = __importDefault(require("node:os"));
9
8
  const node_path_1 = __importDefault(require("node:path"));
10
9
  const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_crypto_1 = __importDefault(require("node:crypto"));
11
11
  const node_child_process_1 = require("node:child_process");
12
12
  const camoufox_js_1 = require("camoufox-js");
13
13
  const fingerprints_js_1 = require("camoufox-js/dist/fingerprints.js");
@@ -17,18 +17,12 @@ const fingerprints_js_1 = require("camoufox-js/dist/fingerprints.js");
17
17
  const pkgman_js_1 = require("camoufox-js/dist/pkgman.js");
18
18
  const playwright_core_1 = require("playwright-core");
19
19
  const config_1 = require("../utils/config");
20
+ const platform_support_1 = require("../utils/platform-support");
21
+ const profile_path_1 = require("../utils/profile-path");
20
22
  const sidecar_version_1 = require("../utils/sidecar-version");
21
23
  const logging_1 = require("../middleware/logging");
22
24
  const CONFIG = (0, config_1.loadConfig)();
23
25
  const MAX_CONTEXTS = CONFIG.maxSessions;
24
- function getHostOS() {
25
- const platform = node_os_1.default.platform();
26
- if (platform === 'darwin')
27
- return 'macos';
28
- if (platform === 'win32')
29
- return 'windows';
30
- return 'linux';
31
- }
32
26
  function buildProxyConfig(proxy) {
33
27
  if (!proxy) {
34
28
  // Fallback to configured server proxy
@@ -55,43 +49,6 @@ function getInstalledCamoufoxVersion() {
55
49
  return 'unknown';
56
50
  }
57
51
  }
58
- function decodeDefaultProfileUserId(profileKey) {
59
- if (!profileKey.startsWith('u:') || profileKey.indexOf(':', 2) !== -1)
60
- return null;
61
- try {
62
- const encoded = profileKey.slice(2);
63
- const bytes = Buffer.from(encoded, 'base64url');
64
- if (bytes.length % 2 !== 0)
65
- return null;
66
- const decoded = bytes.toString('utf16le');
67
- if (/^(?:u|s|p|o):/.test(decoded))
68
- return null;
69
- for (let i = 0; i < decoded.length; i += 1) {
70
- const code = decoded.charCodeAt(i);
71
- if (code >= 0xd800 && code <= 0xdbff) {
72
- if (i + 1 >= decoded.length)
73
- return null;
74
- const next = decoded.charCodeAt(i + 1);
75
- if (next < 0xdc00 || next > 0xdfff)
76
- return null;
77
- i += 1;
78
- }
79
- else if (code >= 0xdc00 && code <= 0xdfff) {
80
- return null;
81
- }
82
- }
83
- return `u:${Buffer.from(decoded, 'utf16le').toString('base64url')}` === profileKey ? decoded : null;
84
- }
85
- catch {
86
- return null;
87
- }
88
- }
89
- function profileDirForProfileKey(profileKey) {
90
- // Avoid path traversal from untrusted route params.
91
- const defaultUserId = decodeDefaultProfileUserId(String(profileKey));
92
- const safe = encodeURIComponent(defaultUserId ?? String(profileKey));
93
- return node_path_1.default.join(CONFIG.profilesDir, safe);
94
- }
95
52
  function pickSeedOptions(opts) {
96
53
  if (!opts)
97
54
  return undefined;
@@ -117,59 +74,263 @@ function hasUsableLinuxDisplay() {
117
74
  return node_fs_1.default.existsSync(lockFile);
118
75
  }
119
76
  async function spawnXvfb(resolution = '1920x1080x24') {
120
- let displayNum = 99;
121
- while (node_fs_1.default.existsSync(`/tmp/.X${displayNum}-lock`)) {
122
- displayNum++;
123
- }
124
- const display = `:${displayNum}`;
77
+ // Use -displayfd so Xvfb itself picks a free display number atomically.
78
+ // This avoids the userspace race condition where multiple concurrent
79
+ // spawnXvfb() calls all see the same absence of a lock file and pick
80
+ // the same display number — only the first to create the lock succeeds,
81
+ // the others exit with code 1.
82
+ //
83
+ // Xvfb writes "<display>\n" to fd 3 (e.g. "99\n"), which we read to
84
+ // determine the assigned display. This is the same approach used by
85
+ // camoufox-js's VirtualDisplay class (apify/camoufox-js#273).
86
+ //
87
+ // The fd3 output is line-delimited. We buffer until a newline is
88
+ // received and parse the complete record, rather than matching
89
+ // digits greedily — chunked writes could deliver a partial number
90
+ // (e.g. "9" then "9\n") that would be accepted prematurely.
125
91
  const xvfbProcess = (0, node_child_process_1.spawn)('Xvfb', [
126
- display,
127
- '-screen',
128
- '0',
129
- resolution,
130
- '-ac',
131
- '-nolisten',
132
- 'tcp',
133
- ], { stdio: 'pipe' });
134
- await new Promise((resolve, reject) => {
92
+ '-displayfd', '3',
93
+ '-screen', '0', resolution,
94
+ '-ac', '-nolisten', 'tcp',
95
+ ], {
96
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
97
+ });
98
+ // Idempotent child cleanup — safe to call multiple times from any
99
+ // error path (timeout, spawn error, fd3 error/close).
100
+ // SIGTERM first, then SIGKILL after 3s if still alive.
101
+ // When the child has already exited (childExited=true), cleanupChild()
102
+ // is a no-op — no SIGTERM to a dead process, no new escalation timer.
103
+ let childCleaned = false;
104
+ let childExited = false;
105
+ let sigkillTimer = null;
106
+ const cleanupChild = () => {
107
+ if (childCleaned)
108
+ return;
109
+ childCleaned = true;
110
+ if (childExited)
111
+ return;
112
+ try {
113
+ xvfbProcess.kill('SIGTERM');
114
+ }
115
+ catch {
116
+ // already dead
117
+ }
118
+ sigkillTimer = setTimeout(() => {
119
+ sigkillTimer = null;
120
+ try {
121
+ xvfbProcess.kill('SIGKILL');
122
+ }
123
+ catch {
124
+ // already dead
125
+ }
126
+ }, 3000);
127
+ sigkillTimer.unref();
128
+ };
129
+ // Mark child as exited and cancel any pending SIGKILL escalation timer.
130
+ // Called from the exit handler and the error handler (error often
131
+ // precedes or accompanies exit). After this, cleanupChild() will not
132
+ // send signals to the already-dead process.
133
+ const onChildExit = () => {
134
+ childExited = true;
135
+ if (sigkillTimer) {
136
+ clearTimeout(sigkillTimer);
137
+ sigkillTimer = null;
138
+ }
139
+ };
140
+ const display = await new Promise((resolve, reject) => {
135
141
  let settled = false;
136
- const finalizeResolve = () => {
142
+ const finalize = (fn) => {
137
143
  if (settled)
138
144
  return;
139
145
  settled = true;
140
- clearInterval(check);
141
146
  clearTimeout(timeout);
142
- resolve();
143
- };
144
- const finalizeReject = (err) => {
145
- if (settled)
146
- return;
147
- settled = true;
148
- clearInterval(check);
149
- clearTimeout(timeout);
150
- reject(err);
147
+ fn();
151
148
  };
152
149
  const timeout = setTimeout(() => {
153
- finalizeReject(new Error('Xvfb start timeout'));
150
+ cleanupChild();
151
+ finalize(() => reject(new Error('Xvfb start timeout')));
154
152
  }, 5000);
155
- const check = setInterval(() => {
156
- if (node_fs_1.default.existsSync(`/tmp/.X${displayNum}-lock`)) {
157
- finalizeResolve();
153
+ const fd3 = xvfbProcess.stdio[3];
154
+ let buf = '';
155
+ fd3.on('data', (chunk) => {
156
+ buf += chunk.toString();
157
+ // Process ALL complete newline-delimited records currently in
158
+ // the buffer. A single data event may contain multiple records
159
+ // (e.g. "\n44\n" or "invalid\n44\n"). We loop through each
160
+ // complete record, skip malformed/blank lines, and resolve on
161
+ // the first valid display number. Any remaining incomplete data
162
+ // stays in buf for the next data event.
163
+ for (;;) {
164
+ const newlineIdx = buf.indexOf('\n');
165
+ if (newlineIdx === -1)
166
+ break;
167
+ const line = buf.slice(0, newlineIdx).trim();
168
+ buf = buf.slice(newlineIdx + 1);
169
+ // Skip blank or malformed records — Xvfb may emit leading
170
+ // newlines or debug output before the display number.
171
+ const match = line.match(/^(\d+)$/);
172
+ if (match) {
173
+ finalize(() => resolve(`:${match[1]}`));
174
+ return;
175
+ }
158
176
  }
159
- }, 100);
177
+ });
160
178
  xvfbProcess.once('error', (err) => {
161
- finalizeReject(err);
179
+ cleanupChild();
180
+ onChildExit();
181
+ finalize(() => reject(err));
162
182
  });
163
183
  xvfbProcess.once('exit', (code, signal) => {
164
- finalizeReject(new Error(`Xvfb exited early (code=${code ?? 'null'}, signal=${signal ?? 'null'})`));
184
+ onChildExit();
185
+ finalize(() => reject(new Error(`Xvfb exited early (code=${code ?? 'null'}, signal=${signal ?? 'null'})`)));
186
+ });
187
+ // fd3 may close before data arrives (e.g. Xvfb exits immediately).
188
+ fd3.once('end', () => {
189
+ if (!settled) {
190
+ cleanupChild();
191
+ finalize(() => reject(new Error('Xvfb fd3 closed before writing display number')));
192
+ }
193
+ });
194
+ // fd3 stream error — route through the same finalizer to avoid
195
+ // unhandled stream errors. Guard with `!settled` so that a late
196
+ // fd3 error after successful startup does not terminate a healthy
197
+ // Xvfb child (same guard as the end/close handlers).
198
+ fd3.once('error', (err) => {
199
+ if (!settled) {
200
+ cleanupChild();
201
+ finalize(() => reject(new Error(`Xvfb fd3 stream error: ${err instanceof Error ? err.message : String(err)}`)));
202
+ }
203
+ });
204
+ // fd3 closed without end event — the pipe is fully destroyed.
205
+ // Reject immediately rather than waiting for the 5s timeout.
206
+ fd3.once('close', () => {
207
+ if (!settled) {
208
+ cleanupChild();
209
+ finalize(() => reject(new Error('Xvfb fd3 stream closed before writing display number')));
210
+ }
165
211
  });
166
212
  });
167
213
  return { display, process: xvfbProcess };
168
214
  }
215
+ // Test-only export of spawnXvfb for unit testing the fd3 parser and
216
+ // child cleanup logic without a real Xvfb binary. Not part of the
217
+ // public API; consumed by tests/unit/spawn-xvfb-displayfd.test.js.
218
+ exports.spawnXvfbForTests = spawnXvfb;
169
219
  class ContextPool {
170
220
  pool = new Map();
171
221
  headlessOverrides = new Map();
172
222
  onEvictCallbacks = [];
223
+ activeAdmissions = 0;
224
+ activeAdmissionsByUser = new Map();
225
+ lifecycleEpoch = 0;
226
+ lifecycleEpochByUser = new Map();
227
+ globalClosure = null;
228
+ userClosures = new Map();
229
+ createClosureState() {
230
+ let resolve;
231
+ let resolveDrained;
232
+ return {
233
+ pending: 0,
234
+ promise: new Promise((r) => { resolve = r; }),
235
+ resolve,
236
+ drained: new Promise((r) => { resolveDrained = r; }),
237
+ resolveDrained,
238
+ };
239
+ }
240
+ releaseAdmission(userId) {
241
+ this.activeAdmissions--;
242
+ const remainingForUser = (this.activeAdmissionsByUser.get(userId) ?? 1) - 1;
243
+ if (remainingForUser <= 0) {
244
+ this.activeAdmissionsByUser.delete(userId);
245
+ this.userClosures.get(userId)?.resolveDrained();
246
+ }
247
+ else {
248
+ this.activeAdmissionsByUser.set(userId, remainingForUser);
249
+ }
250
+ if (this.activeAdmissions <= 0)
251
+ this.globalClosure?.resolveDrained();
252
+ }
253
+ async acquireAdmission(userId) {
254
+ const normalized = String(userId);
255
+ for (;;) {
256
+ const globalFence = this.globalClosure;
257
+ const userFence = this.userClosures.get(normalized);
258
+ if (globalFence)
259
+ await globalFence.promise;
260
+ if (userFence)
261
+ await userFence.promise;
262
+ this.activeAdmissions++;
263
+ this.activeAdmissionsByUser.set(normalized, (this.activeAdmissionsByUser.get(normalized) ?? 0) + 1);
264
+ if (!this.globalClosure && !this.userClosures.has(normalized)) {
265
+ let released = false;
266
+ const release = () => {
267
+ if (released)
268
+ return;
269
+ released = true;
270
+ this.releaseAdmission(normalized);
271
+ };
272
+ return {
273
+ release,
274
+ globalEpoch: this.lifecycleEpoch,
275
+ userEpoch: this.lifecycleEpochByUser.get(normalized) ?? 0,
276
+ };
277
+ }
278
+ this.releaseAdmission(normalized);
279
+ }
280
+ }
281
+ async beginUserClosure(userId) {
282
+ const normalized = String(userId);
283
+ let state = this.userClosures.get(normalized);
284
+ if (!state) {
285
+ this.lifecycleEpochByUser.set(normalized, (this.lifecycleEpochByUser.get(normalized) ?? 0) + 1);
286
+ state = this.createClosureState();
287
+ this.userClosures.set(normalized, state);
288
+ if ((this.activeAdmissionsByUser.get(normalized) ?? 0) === 0)
289
+ state.resolveDrained();
290
+ }
291
+ state.pending++;
292
+ await state.drained;
293
+ let released = false;
294
+ return () => {
295
+ if (released)
296
+ return;
297
+ released = true;
298
+ const current = this.userClosures.get(normalized);
299
+ if (!current)
300
+ return;
301
+ current.pending--;
302
+ if (current.pending <= 0) {
303
+ this.userClosures.delete(normalized);
304
+ current.resolve();
305
+ }
306
+ };
307
+ }
308
+ async beginGlobalClosure() {
309
+ let state = this.globalClosure;
310
+ if (!state) {
311
+ this.lifecycleEpoch++;
312
+ state = this.createClosureState();
313
+ this.globalClosure = state;
314
+ if (this.activeAdmissions === 0)
315
+ state.resolveDrained();
316
+ }
317
+ state.pending++;
318
+ await state.drained;
319
+ let released = false;
320
+ return () => {
321
+ if (released)
322
+ return;
323
+ released = true;
324
+ const current = this.globalClosure;
325
+ if (!current)
326
+ return;
327
+ current.pending--;
328
+ if (current.pending <= 0) {
329
+ this.globalClosure = null;
330
+ current.resolve();
331
+ }
332
+ };
333
+ }
173
334
  onEvict(callback) {
174
335
  this.onEvictCallbacks.push(callback);
175
336
  }
@@ -250,10 +411,11 @@ class ContextPool {
250
411
  }
251
412
  }
252
413
  async launchPersistentContext(userId, profileKey, contextOptions, resolvedProxy) {
253
- const hostOS = getHostOS();
414
+ const hostOS = (0, platform_support_1.getHostOS)();
254
415
  const proxy = buildProxyConfig(resolvedProxy);
255
416
  const headless = this.headlessOverrides.get(userId) ?? CONFIG.headless;
256
- const profileDir = profileDirForProfileKey(profileKey);
417
+ (0, platform_support_1.assertBrowserPlatformSupported)(hostOS, (0, platform_support_1.getHostArchitecture)(), headless);
418
+ const profileDir = (0, profile_path_1.resolveProfileDirForProfileKey)(CONFIG.profilesDir, profileKey);
257
419
  node_fs_1.default.mkdirSync(profileDir, { recursive: true });
258
420
  const compatPath = node_path_1.default.join(profileDir, 'compatibility.json');
259
421
  try {
@@ -436,7 +598,7 @@ class ContextPool {
436
598
  return;
437
599
  let lru = null;
438
600
  for (const entry of this.pool.values()) {
439
- if (entry.launching || entry.staged)
601
+ if (entry.launching || entry.closing || entry.staged)
440
602
  continue;
441
603
  if (!lru || entry.lastAccess < lru.lastAccess)
442
604
  lru = entry;
@@ -448,9 +610,33 @@ class ContextPool {
448
610
  await this.closeContext(lru.profileKey);
449
611
  }
450
612
  async ensureContext(profileKey, userId, options, resolvedProxy, staged = false, stagedGeneration) {
613
+ const admission = await this.acquireAdmission(userId);
614
+ try {
615
+ const entry = await this.ensureContextAdmitted(profileKey, userId, options, resolvedProxy, staged, stagedGeneration);
616
+ const normalized = String(userId);
617
+ if (admission.globalEpoch !== this.lifecycleEpoch ||
618
+ admission.userEpoch !== (this.lifecycleEpochByUser.get(normalized) ?? 0)) {
619
+ await this.closeContextIfMatches(profileKey, entry.generation).catch(() => { });
620
+ throw new Error('Context admission superseded by session closure');
621
+ }
622
+ return entry;
623
+ }
624
+ finally {
625
+ admission.release();
626
+ }
627
+ }
628
+ async ensureContextAdmitted(profileKey, userId, options, resolvedProxy, staged = false, stagedGeneration) {
451
629
  const normalized = String(userId);
452
630
  let entry = this.pool.get(profileKey);
453
631
  const seed = pickSeedOptions(options);
632
+ if (entry?.closing) {
633
+ // Never hand out a context once shutdown has started. A request can
634
+ // pass the lifecycle idle-closure check immediately before cleanup
635
+ // begins closing the pool entry; wait for that exact close to finish,
636
+ // then re-read the pool and relaunch if needed.
637
+ await entry.closing;
638
+ entry = this.pool.get(profileKey);
639
+ }
454
640
  if (entry) {
455
641
  if (staged) {
456
642
  entry.staged = true;
@@ -484,7 +670,7 @@ class ContextPool {
484
670
  entry.lastAccess = Date.now();
485
671
  return entry;
486
672
  }
487
- const profileDir = profileDirForProfileKey(profileKey);
673
+ const profileDir = (0, profile_path_1.resolveProfileDirForProfileKey)(CONFIG.profilesDir, profileKey);
488
674
  const newEntry = {
489
675
  context: null,
490
676
  userId: normalized,
@@ -492,6 +678,7 @@ class ContextPool {
492
678
  profileDir,
493
679
  lastAccess: Date.now(),
494
680
  createdAt: Date.now(),
681
+ generation: node_crypto_1.default.randomUUID(),
495
682
  staged,
496
683
  stagedGeneration,
497
684
  proxyConfig: resolvedProxy || null,
@@ -506,13 +693,17 @@ class ContextPool {
506
693
  context.on('close', () => {
507
694
  this.cleanupVirtualDisplay(newEntry);
508
695
  (0, logging_1.log)('info', 'persistent context closed', { userId: normalized, profileKey, profileDir });
509
- this.pool.delete(profileKey);
696
+ if (this.pool.get(profileKey) === newEntry) {
697
+ this.pool.delete(profileKey);
698
+ }
510
699
  });
511
700
  return context;
512
701
  })
513
702
  .catch((err) => {
514
703
  this.cleanupVirtualDisplay(newEntry);
515
- this.pool.delete(profileKey);
704
+ if (this.pool.get(profileKey) === newEntry) {
705
+ this.pool.delete(profileKey);
706
+ }
516
707
  const message = err instanceof Error ? err.message : String(err);
517
708
  (0, logging_1.log)('error', 'persistent context launch failed', { userId: normalized, profileKey, profileDir, error: message });
518
709
  throw err;
@@ -527,32 +718,147 @@ class ContextPool {
527
718
  const entry = this.pool.get(normalized);
528
719
  if (!entry || entry.staged)
529
720
  return;
530
- try {
531
- if (entry.launching) {
532
- await entry.launching.catch(() => { });
533
- entry.launching = undefined;
534
- }
535
- await entry.context?.close().catch(() => { });
721
+ if (entry.closing) {
722
+ await entry.closing;
723
+ return;
536
724
  }
537
- finally {
538
- this.cleanupVirtualDisplay(entry);
539
- // Only delete if this entry is still in the pool (avoid deleting a newer entry with same key)
540
- const currentEntry = this.pool.get(normalized);
541
- if (currentEntry === entry) {
542
- this.pool.delete(normalized);
543
- (0, logging_1.log)('info', 'persistent context removed from pool', { userId: entry.userId, profileKey: normalized, profileDir: entry.profileDir });
725
+ entry.closing = (async () => {
726
+ try {
727
+ if (entry.launching) {
728
+ await entry.launching.catch(() => { });
729
+ entry.launching = undefined;
730
+ }
731
+ await entry.context?.close().catch(() => { });
544
732
  }
545
- else {
546
- (0, logging_1.log)('info', 'persistent context closed but newer entry exists', { userId: entry.userId, profileKey: normalized, profileDir: entry.profileDir });
733
+ finally {
734
+ this.cleanupVirtualDisplay(entry);
735
+ // Only delete if this entry is still in the pool (avoid deleting a newer entry with same key)
736
+ const currentEntry = this.pool.get(normalized);
737
+ if (currentEntry === entry) {
738
+ this.pool.delete(normalized);
739
+ (0, logging_1.log)('info', 'persistent context removed from pool', { userId: entry.userId, profileKey: normalized, profileDir: entry.profileDir });
740
+ }
741
+ else {
742
+ (0, logging_1.log)('info', 'persistent context closed but newer entry exists', { userId: entry.userId, profileKey: normalized, profileDir: entry.profileDir });
743
+ }
744
+ }
745
+ })();
746
+ await entry.closing;
747
+ }
748
+ /**
749
+ * Close the context backing a specific session/profile for a user, but
750
+ * only that one — never unrelated sessions belonging to the same user.
751
+ *
752
+ * This is the blast-radius fix for nav recovery: a single failing tab's
753
+ * session is torn down without terminating the user's other sessions.
754
+ * The `profileKey` is the pool key (which is the sessionMapKey for
755
+ * established session profiles).
756
+ *
757
+ * Fail-closed semantics: when an explicit `profileKey` is provided but
758
+ * does not map to a matching context owned by this user, the call is a
759
+ * no-op — it does NOT fall back to user-wide close. A stale route key
760
+ * must never close every sibling session. User-wide fallback is reserved
761
+ * for genuinely legacy callers that pass no profile key at all.
762
+ */
763
+ async closeContextBySession(userId, profileKey) {
764
+ const normalizedUser = String(userId);
765
+ if (profileKey !== undefined && profileKey !== null && profileKey !== '') {
766
+ const normalizedProfile = String(profileKey);
767
+ // Capture the current pool entry's immutable generation before
768
+ // any await. This prevents the replacement race: if a new entry
769
+ // with the same key appears during teardown, the generation check
770
+ // below will NOT close the newer
771
+ // healthy context.
772
+ const entry = this.pool.get(normalizedProfile);
773
+ // HIGH A — Foreign owner: the pool entry exists but is owned by
774
+ // a different user. This is a fail-closed no-op. A foreign key
775
+ // must never tear down another user's session/context.
776
+ if (entry && entry.userId !== normalizedUser) {
777
+ (0, logging_1.log)('warn', 'closeContextBySession: foreign owner, no-op', {
778
+ requestedUser: normalizedUser,
779
+ actualOwner: entry.userId,
780
+ profileKey: normalizedProfile,
781
+ });
782
+ return;
547
783
  }
784
+ // Staged entries are not eligible for recovery teardown.
785
+ if (entry && entry.staged) {
786
+ (0, logging_1.log)('warn', 'closeContextBySession: staged entry, no-op', {
787
+ profileKey: normalizedProfile,
788
+ });
789
+ return;
790
+ }
791
+ // If the pool entry exists and the owner matches, capture its
792
+ // immutable opaque generation for safe teardown.
793
+ // If the pool entry is missing, generation is undefined — meaning
794
+ // "clean stale session/tab index records only; do NOT close any
795
+ // context, because a new context may have appeared during the
796
+ // await and must not be destroyed" (HIGH B).
797
+ const generation = entry?.generation;
798
+ // Resolve session.ts lazily to avoid the top-level circular dependency,
799
+ // but do it synchronously so the durable session snapshot is captured
800
+ // before this method's first await.
801
+ const { getSessionSnapshot, teardownSessionByKey } = require('./session');
802
+ // Capture the durable session identity (owner + session generation)
803
+ // synchronously, before teardownSessionByKey's internal await
804
+ // (HIGH round 8). This prevents the replacement race where a new
805
+ // session with the same key appears during the context-close await
806
+ // inside teardownSessionByKey. The snapshot's opaque generation is
807
+ // authoritative for session identity; the snapshot's owner is
808
+ // validated against the requesting user for the pool-missing path.
809
+ const sessionSnapshot = getSessionSnapshot(normalizedProfile);
810
+ // Foreign ownership check for the pool-missing path (HIGH round 8):
811
+ // When the pool entry is already gone, the pool-level owner check
812
+ // above could not fire. The durable session snapshot's owner tells
813
+ // us who the session belongs to. If the snapshot owner doesn't
814
+ // match the requesting user, this is a foreign call — fail closed.
815
+ // A foreign user must not delete another user's indexed session
816
+ // records when the pool entry is missing.
817
+ if (generation === undefined && sessionSnapshot.owner !== undefined && sessionSnapshot.owner !== normalizedUser) {
818
+ (0, logging_1.log)('warn', 'closeContextBySession: pool entry missing and session owner is foreign, no-op', {
819
+ requestedUser: normalizedUser,
820
+ sessionOwner: sessionSnapshot.owner,
821
+ profileKey: normalizedProfile,
822
+ });
823
+ return;
824
+ }
825
+ // Full session teardown: unindex tabs, delete session entry,
826
+ // AND close the backing context (only when generation is known).
827
+ // When the pool entry is missing, session/tab records are still
828
+ // cleaned up so findTabById stops returning stale Page objects,
829
+ // but no context close is attempted (HIGH B).
830
+ //
831
+ // The session snapshot is passed through so teardownSessionByKey
832
+ // can validate the session owner (foreign-ownership fail-closed)
833
+ // and the session generation (replacement race prevention).
834
+ await teardownSessionByKey(normalizedProfile, generation, sessionSnapshot);
835
+ return;
548
836
  }
837
+ // No profile key provided — legacy caller without session identity.
838
+ // Only in this case do we fall back to user-wide close.
839
+ await this.closeContextByUserId(normalizedUser);
549
840
  }
550
- async closeContextIfMatches(profileKey, expectedCreatedAt, expectedLastAccess) {
841
+ /**
842
+ * Close all non-staged contexts for a user (user-wide recovery fallback).
843
+ */
844
+ async closeContextByUserId(userId) {
845
+ const normalized = String(userId);
846
+ const entriesToClose = [];
847
+ for (const [profileKey, entry] of this.pool.entries()) {
848
+ if (entry.userId === normalized && !entry.staged) {
849
+ entriesToClose.push(profileKey);
850
+ }
851
+ }
852
+ for (const profileKey of entriesToClose) {
853
+ await this.closeContext(profileKey);
854
+ }
855
+ }
856
+ async closeContextIfMatches(profileKey, expectedGeneration, expectedLastAccess) {
551
857
  const normalized = String(profileKey);
552
858
  const entry = this.pool.get(normalized);
553
859
  if (!entry)
554
860
  return;
555
- if (entry.createdAt !== expectedCreatedAt)
861
+ if (entry.generation !== expectedGeneration)
556
862
  return;
557
863
  // If lastAccess was provided and has changed, the context was reused - don't close
558
864
  if (expectedLastAccess !== undefined && entry.lastAccess !== expectedLastAccess)
@@ -573,19 +879,6 @@ class ContextPool {
573
879
  entry.stagedGeneration = undefined;
574
880
  await this.closeContext(normalized);
575
881
  }
576
- async closeContextByUserId(userId) {
577
- const normalized = String(userId);
578
- // Close all contexts for this userId
579
- const entriesToClose = [];
580
- for (const [profileKey, entry] of this.pool.entries()) {
581
- if (entry.userId === normalized && !entry.staged) {
582
- entriesToClose.push(profileKey);
583
- }
584
- }
585
- for (const profileKey of entriesToClose) {
586
- await this.closeContext(profileKey);
587
- }
588
- }
589
882
  async closeStagedContextByUserId(userId, generation) {
590
883
  const normalized = String(userId);
591
884
  // Close all staged contexts for this userId