@askjo/camofox-browser 1.4.0 → 1.4.1
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/lib/metrics.js +99 -0
- package/lib/proxy.js +19 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/plugin.ts +1 -1
- package/server.js +125 -14
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Prometheus metrics for camofox-browser.
|
|
2
|
+
// Isolated in lib/ to keep process.env out of server.js (OpenClaw scanner rule).
|
|
3
|
+
import client from 'prom-client';
|
|
4
|
+
|
|
5
|
+
const register = new client.Registry();
|
|
6
|
+
client.collectDefaultMetrics({ register });
|
|
7
|
+
|
|
8
|
+
// --- Counters ---
|
|
9
|
+
|
|
10
|
+
export const requestsTotal = new client.Counter({
|
|
11
|
+
name: 'jo_browser_requests_total',
|
|
12
|
+
help: 'Total HTTP requests by action and status',
|
|
13
|
+
labelNames: ['action', 'status'],
|
|
14
|
+
registers: [register],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const tabLockTimeoutsTotal = new client.Counter({
|
|
18
|
+
name: 'jo_browser_tab_lock_timeouts_total',
|
|
19
|
+
help: 'Tab lock queue timeouts resulting in 503',
|
|
20
|
+
registers: [register],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// --- Histograms ---
|
|
24
|
+
|
|
25
|
+
export const requestDuration = new client.Histogram({
|
|
26
|
+
name: 'jo_browser_request_duration_seconds',
|
|
27
|
+
help: 'Request duration in seconds by action',
|
|
28
|
+
labelNames: ['action'],
|
|
29
|
+
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60],
|
|
30
|
+
registers: [register],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const pageLoadDuration = new client.Histogram({
|
|
34
|
+
name: 'jo_browser_page_load_duration_seconds',
|
|
35
|
+
help: 'Page load duration in seconds',
|
|
36
|
+
buckets: [0.5, 1, 2, 5, 10, 20, 30, 60],
|
|
37
|
+
registers: [register],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// --- Gauges ---
|
|
41
|
+
|
|
42
|
+
export const activeTabsGauge = new client.Gauge({
|
|
43
|
+
name: 'jo_browser_active_tabs',
|
|
44
|
+
help: 'Current number of open browser tabs',
|
|
45
|
+
registers: [register],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const tabLockQueueDepth = new client.Gauge({
|
|
49
|
+
name: 'jo_browser_tab_lock_queue_depth',
|
|
50
|
+
help: 'Current number of requests waiting for a tab lock',
|
|
51
|
+
registers: [register],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const memoryUsageBytes = new client.Gauge({
|
|
55
|
+
name: 'jo_browser_memory_usage_bytes',
|
|
56
|
+
help: 'Process RSS memory usage in bytes',
|
|
57
|
+
registers: [register],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Periodic memory reporter
|
|
61
|
+
const MEMORY_INTERVAL_MS = 30_000;
|
|
62
|
+
let memoryTimer = null;
|
|
63
|
+
|
|
64
|
+
export function startMemoryReporter() {
|
|
65
|
+
if (memoryTimer) return;
|
|
66
|
+
const report = () => memoryUsageBytes.set(process.memoryUsage().rss);
|
|
67
|
+
report();
|
|
68
|
+
memoryTimer = setInterval(report, MEMORY_INTERVAL_MS);
|
|
69
|
+
memoryTimer.unref(); // don't keep process alive
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function stopMemoryReporter() {
|
|
73
|
+
if (memoryTimer) { clearInterval(memoryTimer); memoryTimer = null; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Helper: derive a short action name from Express route
|
|
77
|
+
export function actionFromReq(req) {
|
|
78
|
+
const method = req.method;
|
|
79
|
+
const path = req.route?.path || req.path;
|
|
80
|
+
// POST /tabs -> create_tab, DELETE /tabs/:tabId -> delete_tab, etc.
|
|
81
|
+
if (path === '/tabs' && method === 'POST') return 'create_tab';
|
|
82
|
+
if (path === '/tabs/:tabId' && method === 'DELETE') return 'delete_tab';
|
|
83
|
+
if (path === '/tabs/group/:listItemId' && method === 'DELETE') return 'delete_tab_group';
|
|
84
|
+
if (path === '/sessions/:userId' && method === 'DELETE') return 'delete_session';
|
|
85
|
+
if (path === '/sessions/:userId/cookies' && method === 'POST') return 'set_cookies';
|
|
86
|
+
if (path === '/tabs/open' && method === 'POST') return 'open_url';
|
|
87
|
+
if (path === '/tabs' && method === 'GET') return 'list_tabs';
|
|
88
|
+
// /tabs/:tabId/<action>
|
|
89
|
+
const m = path.match(/^\/tabs\/:tabId\/(\w+)$/);
|
|
90
|
+
if (m) return m[1]; // navigate, snapshot, click, type, scroll, etc.
|
|
91
|
+
// legacy compat routes
|
|
92
|
+
if (['/start', '/stop', '/navigate', '/snapshot', '/act'].includes(path)) return path.slice(1);
|
|
93
|
+
if (path === '/youtube/transcript') return 'youtube_transcript';
|
|
94
|
+
if (path === '/health') return 'health';
|
|
95
|
+
if (path === '/metrics') return 'metrics';
|
|
96
|
+
return `${method.toLowerCase()}_${path.replace(/[/:]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { register };
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function decodeProxyCredential(value) {
|
|
2
|
+
if (!value) return value;
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
return decodeURIComponent(value);
|
|
6
|
+
} catch {
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizePlaywrightProxy(proxy) {
|
|
12
|
+
if (!proxy) return proxy;
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
...proxy,
|
|
16
|
+
username: decodeProxyCredential(proxy.username),
|
|
17
|
+
password: decodeProxyCredential(proxy.password),
|
|
18
|
+
};
|
|
19
|
+
}
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askjo/camofox-browser",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"playwright": "^1.50.0",
|
|
67
67
|
"playwright-core": "^1.58.0",
|
|
68
68
|
"playwright-extra": "^4.3.6",
|
|
69
|
+
"prom-client": "^15.1.3",
|
|
69
70
|
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
package/plugin.ts
CHANGED
|
@@ -449,7 +449,7 @@ export default function register(api: PluginApi) {
|
|
|
449
449
|
const userId = ctx.agentId || fallbackUserId;
|
|
450
450
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/evaluate`, {
|
|
451
451
|
method: "POST",
|
|
452
|
-
body: { userId, expression },
|
|
452
|
+
body: JSON.stringify({ userId, expression }),
|
|
453
453
|
});
|
|
454
454
|
return toToolResult(result);
|
|
455
455
|
},
|
package/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import crypto from 'crypto';
|
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import { expandMacro } from './lib/macros.js';
|
|
7
7
|
import { loadConfig } from './lib/config.js';
|
|
8
|
+
import { normalizePlaywrightProxy } from './lib/proxy.js';
|
|
8
9
|
import { windowSnapshot } from './lib/snapshot.js';
|
|
9
10
|
import {
|
|
10
11
|
MAX_DOWNLOAD_INLINE_BYTES,
|
|
@@ -15,6 +16,12 @@ import {
|
|
|
15
16
|
extractPageImages,
|
|
16
17
|
} from './lib/downloads.js';
|
|
17
18
|
import { detectYtDlp, hasYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './lib/youtube.js';
|
|
19
|
+
import {
|
|
20
|
+
register as metricsRegister,
|
|
21
|
+
requestsTotal, requestDuration, pageLoadDuration,
|
|
22
|
+
activeTabsGauge, tabLockQueueDepth,
|
|
23
|
+
tabLockTimeoutsTotal, startMemoryReporter, actionFromReq,
|
|
24
|
+
} from './lib/metrics.js';
|
|
18
25
|
|
|
19
26
|
const CONFIG = loadConfig();
|
|
20
27
|
|
|
@@ -37,20 +44,34 @@ function log(level, msg, fields = {}) {
|
|
|
37
44
|
const app = express();
|
|
38
45
|
app.use(express.json({ limit: '100kb' }));
|
|
39
46
|
|
|
40
|
-
// Request logging middleware
|
|
47
|
+
// Request logging + metrics middleware
|
|
41
48
|
app.use((req, res, next) => {
|
|
42
|
-
if (req.path === '/health') return next();
|
|
43
49
|
const reqId = crypto.randomUUID().slice(0, 8);
|
|
44
50
|
req.reqId = reqId;
|
|
45
51
|
req.startTime = Date.now();
|
|
52
|
+
|
|
46
53
|
const userId = req.body?.userId || req.query?.userId || '-';
|
|
47
|
-
|
|
54
|
+
if (req.path !== '/health') {
|
|
55
|
+
log('info', 'req', { reqId, method: req.method, path: req.path, userId });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const action = actionFromReq(req);
|
|
59
|
+
const done = requestDuration.startTimer({ action });
|
|
60
|
+
|
|
48
61
|
const origEnd = res.end.bind(res);
|
|
49
62
|
res.end = function (...args) {
|
|
50
63
|
const ms = Date.now() - req.startTime;
|
|
51
|
-
|
|
64
|
+
const isErrorStatus = res.statusCode >= 400;
|
|
65
|
+
requestsTotal.labels(action, isErrorStatus ? 'error' : 'success').inc();
|
|
66
|
+
done();
|
|
67
|
+
|
|
68
|
+
if (req.path !== '/health') {
|
|
69
|
+
log('info', 'res', { reqId, status: res.statusCode, ms });
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
return origEnd(...args);
|
|
53
73
|
};
|
|
74
|
+
|
|
54
75
|
next();
|
|
55
76
|
});
|
|
56
77
|
|
|
@@ -243,9 +264,12 @@ class TabLock {
|
|
|
243
264
|
entry.timer = setTimeout(() => {
|
|
244
265
|
const idx = this.queue.indexOf(entry);
|
|
245
266
|
if (idx !== -1) this.queue.splice(idx, 1);
|
|
267
|
+
tabLockTimeoutsTotal.inc();
|
|
268
|
+
refreshTabLockQueueDepth();
|
|
246
269
|
reject(new Error('Tab lock queue timeout'));
|
|
247
270
|
}, timeoutMs);
|
|
248
271
|
this.queue.push(entry);
|
|
272
|
+
refreshTabLockQueueDepth();
|
|
249
273
|
this._tryNext();
|
|
250
274
|
});
|
|
251
275
|
}
|
|
@@ -253,6 +277,7 @@ class TabLock {
|
|
|
253
277
|
release() {
|
|
254
278
|
this.active = false;
|
|
255
279
|
this._tryNext();
|
|
280
|
+
refreshTabLockQueueDepth();
|
|
256
281
|
}
|
|
257
282
|
|
|
258
283
|
_tryNext() {
|
|
@@ -260,6 +285,7 @@ class TabLock {
|
|
|
260
285
|
this.active = true;
|
|
261
286
|
const entry = this.queue.shift();
|
|
262
287
|
clearTimeout(entry.timer);
|
|
288
|
+
refreshTabLockQueueDepth();
|
|
263
289
|
entry.resolve();
|
|
264
290
|
}
|
|
265
291
|
|
|
@@ -270,6 +296,7 @@ class TabLock {
|
|
|
270
296
|
entry.reject(new Error('Tab destroyed'));
|
|
271
297
|
}
|
|
272
298
|
this.queue = [];
|
|
299
|
+
refreshTabLockQueueDepth();
|
|
273
300
|
}
|
|
274
301
|
}
|
|
275
302
|
|
|
@@ -463,6 +490,7 @@ async function launchBrowserInstance() {
|
|
|
463
490
|
proxy: proxy,
|
|
464
491
|
geoip: !!proxy,
|
|
465
492
|
});
|
|
493
|
+
options.proxy = normalizePlaywrightProxy(options.proxy);
|
|
466
494
|
|
|
467
495
|
browser = await firefox.launch(options);
|
|
468
496
|
log('info', 'camoufox launched');
|
|
@@ -612,6 +640,7 @@ function destroyTab(session, tabId) {
|
|
|
612
640
|
if (lock) {
|
|
613
641
|
lock.drain();
|
|
614
642
|
tabLocks.delete(tabId);
|
|
643
|
+
refreshTabLockQueueDepth();
|
|
615
644
|
}
|
|
616
645
|
for (const [listItemId, group] of session.tabGroups) {
|
|
617
646
|
if (group.has(tabId)) {
|
|
@@ -620,6 +649,7 @@ function destroyTab(session, tabId) {
|
|
|
620
649
|
safePageClose(tabState.page);
|
|
621
650
|
group.delete(tabId);
|
|
622
651
|
if (group.size === 0) session.tabGroups.delete(listItemId);
|
|
652
|
+
refreshActiveTabsGauge();
|
|
623
653
|
return true;
|
|
624
654
|
}
|
|
625
655
|
}
|
|
@@ -657,6 +687,27 @@ function createTabState(page) {
|
|
|
657
687
|
};
|
|
658
688
|
}
|
|
659
689
|
|
|
690
|
+
function refreshActiveTabsGauge() {
|
|
691
|
+
activeTabsGauge.set(getTotalTabCount());
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function refreshTabLockQueueDepth() {
|
|
695
|
+
let queued = 0;
|
|
696
|
+
for (const lock of tabLocks.values()) {
|
|
697
|
+
if (lock?.queue) queued += lock.queue.length;
|
|
698
|
+
}
|
|
699
|
+
tabLockQueueDepth.set(queued);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function withPageLoadDuration(action, fn) {
|
|
703
|
+
const end = pageLoadDuration.startTimer();
|
|
704
|
+
try {
|
|
705
|
+
return await fn();
|
|
706
|
+
} finally {
|
|
707
|
+
end();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
660
711
|
|
|
661
712
|
|
|
662
713
|
async function waitForPageReady(page, options = {}) {
|
|
@@ -1205,6 +1256,11 @@ app.get('/health', (req, res) => {
|
|
|
1205
1256
|
});
|
|
1206
1257
|
});
|
|
1207
1258
|
|
|
1259
|
+
app.get('/metrics', async (_req, res) => {
|
|
1260
|
+
res.set('Content-Type', metricsRegister.contentType);
|
|
1261
|
+
res.send(await metricsRegister.metrics());
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1208
1264
|
// Create new tab
|
|
1209
1265
|
app.post('/tabs', async (req, res) => {
|
|
1210
1266
|
try {
|
|
@@ -1235,11 +1291,12 @@ app.post('/tabs', async (req, res) => {
|
|
|
1235
1291
|
const tabState = createTabState(page);
|
|
1236
1292
|
attachDownloadListener(tabState, tabId);
|
|
1237
1293
|
group.set(tabId, tabState);
|
|
1294
|
+
refreshActiveTabsGauge();
|
|
1238
1295
|
|
|
1239
1296
|
if (url) {
|
|
1240
1297
|
const urlErr = validateUrl(url);
|
|
1241
1298
|
if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
|
|
1242
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1299
|
+
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
1243
1300
|
tabState.visitedUrls.add(url);
|
|
1244
1301
|
}
|
|
1245
1302
|
|
|
@@ -1303,6 +1360,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1303
1360
|
attachDownloadListener(tabState, tabId, log);
|
|
1304
1361
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
1305
1362
|
group.set(tabId, tabState);
|
|
1363
|
+
refreshActiveTabsGauge();
|
|
1306
1364
|
log('info', 'tab auto-created on navigate', { reqId: req.reqId, tabId, userId });
|
|
1307
1365
|
}
|
|
1308
1366
|
} else {
|
|
@@ -1311,7 +1369,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1311
1369
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1312
1370
|
|
|
1313
1371
|
let targetUrl = url;
|
|
1314
|
-
if (macro) {
|
|
1372
|
+
if (macro && macro !== '__NO__' && macro !== 'none' && macro !== 'null') {
|
|
1315
1373
|
targetUrl = expandMacro(macro, query) || url;
|
|
1316
1374
|
}
|
|
1317
1375
|
|
|
@@ -1321,7 +1379,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1321
1379
|
if (urlErr) throw new Error(urlErr);
|
|
1322
1380
|
|
|
1323
1381
|
return await withTabLock(tabId, async () => {
|
|
1324
|
-
await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1382
|
+
await withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
1325
1383
|
tabState.visitedUrls.add(targetUrl);
|
|
1326
1384
|
tabState.lastSnapshot = null;
|
|
1327
1385
|
|
|
@@ -1342,8 +1400,8 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1342
1400
|
res.json(result);
|
|
1343
1401
|
} catch (err) {
|
|
1344
1402
|
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
|
|
1345
|
-
const
|
|
1346
|
-
if (
|
|
1403
|
+
const is400 = err.message && (err.message.startsWith('Blocked URL scheme') || err.message === 'url or macro required');
|
|
1404
|
+
if (is400) {
|
|
1347
1405
|
return res.status(400).json({ error: safeError(err) });
|
|
1348
1406
|
}
|
|
1349
1407
|
handleRouteError(err, req, res);
|
|
@@ -1778,7 +1836,17 @@ app.post('/tabs/:tabId/back', async (req, res) => {
|
|
|
1778
1836
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1779
1837
|
|
|
1780
1838
|
const result = await withTabLock(tabId, async () => {
|
|
1781
|
-
|
|
1839
|
+
try {
|
|
1840
|
+
await tabState.page.goBack({ timeout: 10000 });
|
|
1841
|
+
} catch (navErr) {
|
|
1842
|
+
// NS_BINDING_CANCELLED_OLD_LOAD: Firefox cancels the old load when going back.
|
|
1843
|
+
// The navigation itself succeeded — just the prior page's load was interrupted.
|
|
1844
|
+
if (navErr.message && navErr.message.includes('NS_BINDING_CANCELLED')) {
|
|
1845
|
+
log('info', 'goBack cancelled old load (expected)', { reqId: req.reqId, tabId });
|
|
1846
|
+
} else {
|
|
1847
|
+
throw navErr;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1782
1850
|
tabState.refs = await buildRefs(tabState.page);
|
|
1783
1851
|
return { ok: true, url: tabState.page.url() };
|
|
1784
1852
|
});
|
|
@@ -2014,10 +2082,11 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
2014
2082
|
await clearTabDownloads(found.tabState);
|
|
2015
2083
|
await safePageClose(found.tabState.page);
|
|
2016
2084
|
found.group.delete(req.params.tabId);
|
|
2017
|
-
{ const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); }
|
|
2085
|
+
{ const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); refreshTabLockQueueDepth(); }
|
|
2018
2086
|
if (found.group.size === 0) {
|
|
2019
2087
|
session.tabGroups.delete(found.listItemId);
|
|
2020
2088
|
}
|
|
2089
|
+
refreshActiveTabsGauge();
|
|
2021
2090
|
log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
|
|
2022
2091
|
}
|
|
2023
2092
|
res.json({ ok: true });
|
|
@@ -2037,9 +2106,15 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
|
2037
2106
|
for (const [tabId, tabState] of group) {
|
|
2038
2107
|
await clearTabDownloads(tabState);
|
|
2039
2108
|
await safePageClose(tabState.page);
|
|
2040
|
-
tabLocks.
|
|
2109
|
+
const lock = tabLocks.get(tabId);
|
|
2110
|
+
if (lock) {
|
|
2111
|
+
lock.drain();
|
|
2112
|
+
tabLocks.delete(tabId);
|
|
2113
|
+
}
|
|
2041
2114
|
}
|
|
2042
2115
|
session.tabGroups.delete(req.params.listItemId);
|
|
2116
|
+
refreshTabLockQueueDepth();
|
|
2117
|
+
refreshActiveTabsGauge();
|
|
2043
2118
|
log('info', 'tab group closed', { reqId: req.reqId, listItemId: req.params.listItemId, userId });
|
|
2044
2119
|
}
|
|
2045
2120
|
res.json({ ok: true });
|
|
@@ -2058,6 +2133,18 @@ app.delete('/sessions/:userId', async (req, res) => {
|
|
|
2058
2133
|
await clearSessionDownloads(session);
|
|
2059
2134
|
await session.context.close();
|
|
2060
2135
|
sessions.delete(userId);
|
|
2136
|
+
// Remove any lingering tab locks for the session
|
|
2137
|
+
for (const [listItemId, group] of session.tabGroups) {
|
|
2138
|
+
for (const tabId of group.keys()) {
|
|
2139
|
+
const lock = tabLocks.get(tabId);
|
|
2140
|
+
if (lock) {
|
|
2141
|
+
lock.drain();
|
|
2142
|
+
tabLocks.delete(tabId);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
refreshTabLockQueueDepth();
|
|
2147
|
+
refreshActiveTabsGauge();
|
|
2061
2148
|
log('info', 'session closed', { userId });
|
|
2062
2149
|
}
|
|
2063
2150
|
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
@@ -2076,6 +2163,7 @@ setInterval(() => {
|
|
|
2076
2163
|
clearSessionDownloads(session).catch(() => {});
|
|
2077
2164
|
session.context.close().catch(() => {});
|
|
2078
2165
|
sessions.delete(userId);
|
|
2166
|
+
refreshActiveTabsGauge();
|
|
2079
2167
|
log('info', 'session expired', { userId });
|
|
2080
2168
|
}
|
|
2081
2169
|
}
|
|
@@ -2083,6 +2171,7 @@ setInterval(() => {
|
|
|
2083
2171
|
if (sessions.size === 0) {
|
|
2084
2172
|
scheduleBrowserIdleShutdown();
|
|
2085
2173
|
}
|
|
2174
|
+
refreshTabLockQueueDepth();
|
|
2086
2175
|
}, 60_000);
|
|
2087
2176
|
|
|
2088
2177
|
// Per-tab inactivity reaper — close tabs idle for TAB_INACTIVITY_MS
|
|
@@ -2103,6 +2192,8 @@ setInterval(() => {
|
|
|
2103
2192
|
safePageClose(tabState.page);
|
|
2104
2193
|
group.delete(tabId);
|
|
2105
2194
|
{ const _l = tabLocks.get(tabId); if (_l) _l.drain(); tabLocks.delete(tabId); }
|
|
2195
|
+
refreshTabLockQueueDepth();
|
|
2196
|
+
refreshActiveTabsGauge();
|
|
2106
2197
|
}
|
|
2107
2198
|
} else {
|
|
2108
2199
|
tabState._lastReaperCheck = now;
|
|
@@ -2198,8 +2289,9 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
2198
2289
|
const tabState = createTabState(page);
|
|
2199
2290
|
attachDownloadListener(tabState, tabId, log);
|
|
2200
2291
|
group.set(tabId, tabState);
|
|
2292
|
+
refreshActiveTabsGauge();
|
|
2201
2293
|
|
|
2202
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
2294
|
+
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
2203
2295
|
tabState.visitedUrls.add(url);
|
|
2204
2296
|
|
|
2205
2297
|
log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
|
|
@@ -2242,7 +2334,21 @@ app.post('/stop', async (req, res) => {
|
|
|
2242
2334
|
cleanupTasks.push(clearSessionDownloads(session));
|
|
2243
2335
|
}
|
|
2244
2336
|
await Promise.all(cleanupTasks);
|
|
2337
|
+
for (const session of sessions.values()) {
|
|
2338
|
+
for (const [, group] of session.tabGroups) {
|
|
2339
|
+
for (const tabId of group.keys()) {
|
|
2340
|
+
const lock = tabLocks.get(tabId);
|
|
2341
|
+
if (lock) {
|
|
2342
|
+
lock.drain();
|
|
2343
|
+
tabLocks.delete(tabId);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
tabLocks.clear();
|
|
2245
2349
|
sessions.clear();
|
|
2350
|
+
refreshActiveTabsGauge();
|
|
2351
|
+
refreshTabLockQueueDepth();
|
|
2246
2352
|
res.json({ ok: true, stopped: true, profile: 'camoufox' });
|
|
2247
2353
|
} catch (err) {
|
|
2248
2354
|
res.status(500).json({ ok: false, error: safeError(err) });
|
|
@@ -2273,7 +2379,7 @@ app.post('/navigate', async (req, res) => {
|
|
|
2273
2379
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2274
2380
|
|
|
2275
2381
|
const result = await withTabLock(targetId, async () => {
|
|
2276
|
-
await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
2382
|
+
await withPageLoadDuration('navigate', () => tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
2277
2383
|
tabState.visitedUrls.add(url);
|
|
2278
2384
|
tabState.lastSnapshot = null;
|
|
2279
2385
|
|
|
@@ -2641,6 +2747,7 @@ async function gracefulShutdown(signal) {
|
|
|
2641
2747
|
forceTimeout.unref();
|
|
2642
2748
|
|
|
2643
2749
|
server.close();
|
|
2750
|
+
stopMemoryReporter();
|
|
2644
2751
|
|
|
2645
2752
|
for (const [userId, session] of sessions) {
|
|
2646
2753
|
await session.context.close().catch(() => {});
|
|
@@ -2654,12 +2761,16 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
|
2654
2761
|
|
|
2655
2762
|
const PORT = CONFIG.port;
|
|
2656
2763
|
const server = app.listen(PORT, async () => {
|
|
2764
|
+
startMemoryReporter();
|
|
2765
|
+
refreshActiveTabsGauge();
|
|
2766
|
+
refreshTabLockQueueDepth();
|
|
2657
2767
|
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
2658
2768
|
// Pre-warm browser so first request doesn't eat a 6-7s cold start
|
|
2659
2769
|
try {
|
|
2660
2770
|
const start = Date.now();
|
|
2661
2771
|
await ensureBrowser();
|
|
2662
2772
|
log('info', 'browser pre-warmed', { ms: Date.now() - start });
|
|
2773
|
+
scheduleBrowserIdleShutdown();
|
|
2663
2774
|
} catch (err) {
|
|
2664
2775
|
log('error', 'browser pre-warm failed (will retry on first request)', { error: err.message });
|
|
2665
2776
|
}
|