Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -5949,24 +5949,55 @@ function findPiSessionByResumeTarget(resumeTarget, allSessions) {
return (allSessions || []).find(s => s.tool === 'pi' && s.resume_target && path.resolve(s.resume_target) === resolvedTarget) || null;
}

// Which Qwen transcript a pid currently has open, keyed by pid.
// `lsof` is the only precise signal (cwd-matching can't tell two sessions in the
// same folder apart), but it used to run through execSync RIGHT ON the
// /api/active path — which the dashboard polls every 5s. With a 2000ms timeout,
// per pid, that stalled the whole event loop, and the terminal WebSocket data
// pump with it: typing froze while the poll ran. So the lookup is now a cache
// refreshed OFF the loop; callers never wait for lsof.
const _qwenOpenFileCache = new Map(); // pid -> { ts, ids: string[] }
const _qwenOpenFileInflight = new Set(); // pids with a refresh in progress
const QWEN_LSOF_TTL = 30000; // an agent keeps its transcript open

function _refreshQwenOpenFiles(pid) {
if (_qwenOpenFileInflight.has(pid)) return;
_qwenOpenFileInflight.add(pid);
_execFileAsync('lsof', ['-a', '-p', String(pid), '-Fn'], { timeout: 2000, maxBuffer: 8 * 1024 * 1024 })
.then(({ stdout }) => {
const ids = [];
for (const line of String(stdout || '').split('\n')) {
const m = line.match(/(\/.*\.qwen\/projects\/.*\/(?:chats|sessions)\/([0-9a-f-]{36})\.jsonl)$/i);
if (m) ids.push(m[2]);
}
_qwenOpenFileCache.set(pid, { ts: Date.now(), ids });
})
.catch(() => { _qwenOpenFileCache.set(pid, { ts: Date.now(), ids: [] }); })
.finally(() => {
_qwenOpenFileInflight.delete(pid);
// Bound the map: pids are recycled and agents come and go.
if (_qwenOpenFileCache.size > 256) {
for (const [k, v] of _qwenOpenFileCache) {
if (Date.now() - v.ts > QWEN_LSOF_TTL) _qwenOpenFileCache.delete(k);
}
}
});
}

function findQwenSessionByPid(pid, cwd, allSessions) {
const byOpenFile = [];
const byCwd = [];

try {
const lsofOut = execSync(`lsof -a -p ${pid} -Fn 2>/dev/null`, {
encoding: 'utf8',
timeout: 2000,
stdio: ['pipe', 'pipe', 'pipe'],
});
for (const line of lsofOut.split('\n')) {
const match = line.match(/(\/.*\.qwen\/projects\/.*\/(?:chats|sessions)\/([0-9a-f-]{36})\.jsonl)$/i);
if (!match) continue;
const sessionId = match[2];
const cached = _qwenOpenFileCache.get(pid);
if (!cached || (Date.now() - cached.ts) > QWEN_LSOF_TTL) _refreshQwenOpenFiles(pid);
// Until the first refresh lands we simply fall through to the cwd match below,
// which is the same answer lsof would give for a single session in a folder.
if (cached) {
for (const sessionId of cached.ids) {
const session = allSessions.find(s => s.id === sessionId);
if (session) byOpenFile.push(session);
}
} catch {}
}

if (cwd) {
for (const session of allSessions) {
Expand Down
74 changes: 74 additions & 0 deletions test/active-poll-nonblocking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Guards the /api/active path (polled every 5s by the dashboard) against
// synchronous subprocess calls.
//
// Why this matters: the server, the HTTP API and the browser-terminal
// WebSocket all share ONE process and ONE event loop. Anything synchronous on a
// polled path stalls the pty data pump, so typing in the terminal freezes.
// `findQwenSessionByPid` used to run `execSync('lsof …')` — with a 2000ms
// timeout, per pid — right inside `getActiveSessions()`. lsof is still the only
// precise signal for which transcript a pid has open, so it is kept, but the
// lookup now reads a cache refreshed off the loop.
//
// A timing assertion would be useless here (a dev machine usually has zero
// running Qwen agents, so the hot loop never executes and any measurement comes
// back green regardless). Instead this asserts the property structurally.

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');

const SRC = fs.readFileSync(path.join(__dirname, '..', 'src', 'data.js'), 'utf8');

// Exact function body by brace matching — slicing "up to the next `function`"
// is not reliable here (neighbours are declared as `const x = …`, so the slice
// ran on for several functions), and comments must be stripped or a mere
// *mention* of execSync in prose trips the assertions.
function sliceFn(name) {
const start = SRC.indexOf('function ' + name);
assert.notEqual(start, -1, name + ' not found in data.js');
let i = SRC.indexOf('{', start);
assert.notEqual(i, -1, name + ' has no body');
let depth = 0;
for (; i < SRC.length; i++) {
if (SRC[i] === '{') depth++;
else if (SRC[i] === '}' && --depth === 0) { i++; break; }
}
return SRC.slice(start, i)
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/^[ \t]*\/\/.*$/gm, ''); // line comments
}

test('findQwenSessionByPid never shells out synchronously', () => {
const body = sliceFn('findQwenSessionByPid');
assert.equal(/execSync|execFileSync|spawnSync/.test(body), false,
'no synchronous subprocess call may run on the 5s /api/active path');
});

test('the lsof lookup runs off the event loop and is cached', () => {
const body = sliceFn('_refreshQwenOpenFiles');
assert.match(body, /_execFileAsync\(/, 'lsof must be invoked asynchronously');
assert.match(body, /lsof/, 'still uses lsof — it is the only precise signal');
// argv form, not a shell string: the pid is interpolated into the command.
assert.match(body, /\[\s*'-a'\s*,\s*'-p'/, 'lsof args must be passed as argv');
assert.match(SRC, /_qwenOpenFileCache\s*=\s*new Map\(\)/, 'results must be cached per pid');
});

test('an in-flight lsof refresh is not started twice for the same pid', () => {
const body = sliceFn('_refreshQwenOpenFiles');
assert.match(body, /_qwenOpenFileInflight\.has\(pid\)/);
assert.match(body, /_qwenOpenFileInflight\.add\(pid\)/);
assert.match(body, /_qwenOpenFileInflight\.delete\(pid\)/);
});

test('the pid cache is bounded so it cannot grow without limit', () => {
const body = sliceFn('_refreshQwenOpenFiles');
assert.match(body, /_qwenOpenFileCache\.size\s*>/, 'must cap the cache');
assert.match(body, /_qwenOpenFileCache\.delete\(/, 'must evict stale entries');
});

test('getActiveSessions itself stays free of synchronous subprocess calls', () => {
const body = sliceFn('getActiveSessions');
assert.equal(/execSync\(|execFileSync\(|spawnSync\(/.test(body), false,
'getActiveSessions is polled every 5s — it must not block the loop');
});
Loading