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.
- package/CHANGELOG.md +26 -0
- package/README.md +28 -4
- package/THIRD-PARTY-NOTICES.md +49 -0
- package/dist/src/cli/server/manager.d.ts.map +1 -1
- package/dist/src/cli/server/manager.js +1 -4
- package/dist/src/cli/server/manager.js.map +1 -1
- package/dist/src/routes/core.d.ts.map +1 -1
- package/dist/src/routes/core.js +46 -1
- package/dist/src/routes/core.js.map +1 -1
- package/dist/src/routes/openclaw.d.ts.map +1 -1
- package/dist/src/routes/openclaw.js +35 -1
- package/dist/src/routes/openclaw.js.map +1 -1
- package/dist/src/services/context-pool.d.ts +42 -2
- package/dist/src/services/context-pool.d.ts.map +1 -1
- package/dist/src/services/context-pool.js +408 -115
- package/dist/src/services/context-pool.js.map +1 -1
- package/dist/src/services/health.d.ts +57 -6
- package/dist/src/services/health.d.ts.map +1 -1
- package/dist/src/services/health.js +132 -11
- package/dist/src/services/health.js.map +1 -1
- package/dist/src/services/nav-recovery.d.ts +23 -0
- package/dist/src/services/nav-recovery.d.ts.map +1 -0
- package/dist/src/services/nav-recovery.js +63 -0
- package/dist/src/services/nav-recovery.js.map +1 -0
- package/dist/src/services/session.d.ts +84 -0
- package/dist/src/services/session.d.ts.map +1 -1
- package/dist/src/services/session.js +338 -35
- package/dist/src/services/session.js.map +1 -1
- package/dist/src/services/tab.d.ts +5 -1
- package/dist/src/services/tab.d.ts.map +1 -1
- package/dist/src/services/tab.js +114 -51
- package/dist/src/services/tab.js.map +1 -1
- package/dist/src/services/tracing.js +2 -2
- package/dist/src/services/tracing.js.map +1 -1
- package/dist/src/services/vnc.d.ts.map +1 -1
- package/dist/src/services/vnc.js +2 -0
- package/dist/src/services/vnc.js.map +1 -1
- package/dist/src/types.d.ts +2 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/config.d.ts +3 -0
- package/dist/src/utils/config.d.ts.map +1 -1
- package/dist/src/utils/config.js +3 -0
- package/dist/src/utils/config.js.map +1 -1
- package/dist/src/utils/launcher.js +1 -1
- package/dist/src/utils/launcher.js.map +1 -1
- package/dist/src/utils/platform-support.d.ts +17 -0
- package/dist/src/utils/platform-support.d.ts.map +1 -0
- package/dist/src/utils/platform-support.js +57 -0
- package/dist/src/utils/platform-support.js.map +1 -0
- package/dist/src/utils/profile-path.d.ts +9 -0
- package/dist/src/utils/profile-path.d.ts.map +1 -0
- package/dist/src/utils/profile-path.js +105 -0
- package/dist/src/utils/profile-path.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +10 -3
- package/dist/src/cli/utils/api-fallback.d.ts +0 -4
- package/dist/src/cli/utils/api-fallback.d.ts.map +0 -1
- package/dist/src/cli/utils/api-fallback.js +0 -20
- 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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
'-screen',
|
|
128
|
-
'
|
|
129
|
-
|
|
130
|
-
'
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
142
|
+
const finalize = (fn) => {
|
|
137
143
|
if (settled)
|
|
138
144
|
return;
|
|
139
145
|
settled = true;
|
|
140
|
-
clearInterval(check);
|
|
141
146
|
clearTimeout(timeout);
|
|
142
|
-
|
|
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
|
-
|
|
150
|
+
cleanupChild();
|
|
151
|
+
finalize(() => reject(new Error('Xvfb start timeout')));
|
|
154
152
|
}, 5000);
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
}
|
|
177
|
+
});
|
|
160
178
|
xvfbProcess.once('error', (err) => {
|
|
161
|
-
|
|
179
|
+
cleanupChild();
|
|
180
|
+
onChildExit();
|
|
181
|
+
finalize(() => reject(err));
|
|
162
182
|
});
|
|
163
183
|
xvfbProcess.once('exit', (code, signal) => {
|
|
164
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
entry.launching = undefined;
|
|
534
|
-
}
|
|
535
|
-
await entry.context?.close().catch(() => { });
|
|
721
|
+
if (entry.closing) {
|
|
722
|
+
await entry.closing;
|
|
723
|
+
return;
|
|
536
724
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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.
|
|
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
|