fix: stabilize session diagnostics and Windows session loading

Fix duplicated health probe URL in diagnostics
Share session list proxy handling across platforms
Avoid repeated hanging session requests on Windows
This commit is contained in:
Bohdan Triapitsyn
2026-06-16 14:05:44 +03:00
parent 9199798a14
commit 8f1da2f728
2 changed files with 82 additions and 52 deletions
+1 -1
View File
@@ -235,7 +235,7 @@ export const buildOpenCodeStatusReport = async (): Promise<string> => {
}; };
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [ const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
{ label: 'health', path: '/api/health', includeDirectory: false }, { label: 'health', path: '/health', includeDirectory: false },
{ label: 'config', path: '/config', includeDirectory: true }, { label: 'config', path: '/config', includeDirectory: true },
{ label: 'providers', path: '/config/providers', includeDirectory: true }, { label: 'providers', path: '/config/providers', includeDirectory: true },
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 }, { label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
+81 -51
View File
@@ -411,44 +411,69 @@ export const registerOpenCodeProxy = (app, deps) => {
} }
}; };
const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => { const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
try { const headers = req
const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0 ? {
? req.originalUrl
: (typeof req.url === 'string' ? req.url : '');
const upstreamPathRaw = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
const upstreamPath = await canonicalizeDirectoryQuery(upstreamPathRaw);
const upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), {
method: 'GET',
headers: {
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()), ...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
accept: 'application/json', accept: 'application/json',
'accept-encoding': 'identity', 'accept-encoding': 'identity',
}, }
}); : {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
'accept-encoding': 'identity',
};
const upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), {
method: 'GET',
headers,
...(typeof timeoutMs === 'number' ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
});
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
const bodyText = await upstream.text();
const isJson = contentType.toLowerCase().includes('application/json');
res.status(upstream.status); if (!isJson) {
applyForwardProxyResponseHeaders(upstream.headers, res); return { upstream, contentType, bodyText, payload: null, isJson: false };
}
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8'; try {
const bodyText = await upstream.text(); const payload = JSON.parse(bodyText);
if (!contentType.toLowerCase().includes('application/json')) { return { upstream, contentType, bodyText, payload, isJson: true, parseError: null };
res.setHeader('content-type', contentType); } catch (parseError) {
res.end(bodyText); return { upstream, contentType, bodyText, payload: null, isJson: true, parseError };
}
};
const getRequestUpstreamPath = async (req) => {
const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
? req.originalUrl
: (typeof req.url === 'string' ? req.url : '');
const upstreamPathRaw = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
return canonicalizeDirectoryQuery(upstreamPathRaw);
};
const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => {
try {
const upstreamPath = await getRequestUpstreamPath(req);
const result = await fetchSessionListPayload(upstreamPath, { req });
res.status(result.upstream.status);
applyForwardProxyResponseHeaders(result.upstream.headers, res);
if (!result.isJson) {
res.setHeader('content-type', result.contentType);
res.end(result.bodyText);
return; return;
} }
let payload; if (result.parseError || !Array.isArray(result.payload)) {
try { res.setHeader('content-type', result.contentType);
payload = JSON.parse(bodyText); res.end(result.bodyText);
} catch {
res.setHeader('content-type', contentType);
res.end(bodyText);
return; return;
} }
res.setHeader('content-type', contentType); res.setHeader('content-type', result.contentType);
res.json(sanitizeSessionListPayload(payload)); res.json(sanitizeSessionListPayload(result.payload));
} catch (error) { } catch (error) {
if (isAbortError(error)) { if (isAbortError(error)) {
return; return;
@@ -529,16 +554,17 @@ export const registerOpenCodeProxy = (app, deps) => {
const rawUrl = req.originalUrl || req.url || ''; const rawUrl = req.originalUrl || req.url || '';
if (rawUrl.includes('directory=')) return next(); if (rawUrl.includes('directory=')) return next();
const fetchWindowsSessionList = async (sessionPath) => {
const result = await fetchSessionListPayload(sessionPath, { req, timeoutMs: 10000 });
if (!result.upstream.ok || !Array.isArray(result.payload)) return null;
return sanitizeSessionListPayload(result.payload);
};
try { try {
const authHeaders = getOpenCodeAuthHeaders(); const globalSessions = await fetchWindowsSessionList('/session').catch((error) => {
const fetchOpts = { console.log(`[SessionMerge] Global session list failed: ${error.message}`);
method: 'GET', return null;
headers: { Accept: 'application/json', ...authHeaders }, });
signal: AbortSignal.timeout(10000),
};
const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts);
const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : [];
const globalSessions = Array.isArray(globalPayload) ? globalPayload : [];
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
let projectDirs = []; let projectDirs = [];
@@ -552,11 +578,12 @@ export const registerOpenCodeProxy = (app, deps) => {
} }
const seen = new Set( const seen = new Set(
globalSessions (globalSessions || [])
.map((session) => (session && typeof session.id === 'string' ? session.id : null)) .map((session) => (session && typeof session.id === 'string' ? session.id : null))
.filter((id) => typeof id === 'string') .filter((id) => typeof id === 'string')
); );
const extraSessions = []; const extraSessions = [];
let successfulProjectReads = 0;
for (const dir of projectDirs) { for (const dir of projectDirs) {
const candidates = Array.from(new Set([ const candidates = Array.from(new Set([
dir, dir,
@@ -566,16 +593,15 @@ export const registerOpenCodeProxy = (app, deps) => {
for (const candidateDir of candidates) { for (const candidateDir of candidates) {
const encoded = encodeURIComponent(candidateDir); const encoded = encodeURIComponent(candidateDir);
try { try {
const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); const dirSessions = await fetchWindowsSessionList(`/session?directory=${encoded}`);
if (dirRes.ok) { if (dirSessions) {
const dirPayload = await dirRes.json().catch(() => []); successfulProjectReads += 1;
const dirSessions = Array.isArray(dirPayload) ? dirPayload : []; }
for (const session of dirSessions) { for (const session of dirSessions || []) {
const id = session && typeof session.id === 'string' ? session.id : null; const id = session && typeof session.id === 'string' ? session.id : null;
if (id && !seen.has(id)) { if (id && !seen.has(id)) {
seen.add(id); seen.add(id);
extraSessions.push(session); extraSessions.push(session);
}
} }
} }
} catch { } catch {
@@ -583,17 +609,21 @@ export const registerOpenCodeProxy = (app, deps) => {
} }
} }
const merged = [...globalSessions, ...extraSessions]; if (!globalSessions && successfulProjectReads === 0) {
return res.status(504).json({ error: 'OpenCode session list timed out' });
}
const merged = [...(globalSessions || []), ...extraSessions];
merged.sort((a, b) => { merged.sort((a, b) => {
const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0; const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0;
const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0; const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0;
return bTime - aTime; return bTime - aTime;
}); });
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`); console.log(`[SessionMerge] ${globalSessions?.length || 0} global + ${extraSessions.length} extra = ${merged.length} total`);
return res.json(sanitizeSessionListPayload(merged)); return res.json(sanitizeSessionListPayload(merged));
} catch (error) { } catch (error) {
console.log(`[SessionMerge] Error: ${error.message}, falling through`); console.log(`[SessionMerge] Error: ${error.message}`);
next(); return res.status(500).json({ error: error.message || 'Failed to merge Windows sessions' });
} }
}); });
} }