Skip to content

Commit 781a921

Browse files
fix: eliminate repeated JSONL delta reconstruction that starves extension host (#565)
Root cause: the extension host's single-threaded event loop was blocked by repeated synchronous split+JSON.parse+applyDelta loops on the same large delta-based JSONL files across multiple analysis helpers, triggering the VS Code unresponsive watchdog and crash-restart loop. Three fixes: 1. usageAnalysis.ts: the delta-based JSONL early-return branch in analyzeSessionUsage now computes model switching inline from the already-reconstructed sessionState instead of calling calculateModelSwitching (which re-read the file and called getModelUsageFromSession for yet another re-read). The non-delta JSONL and regular JSON paths now pass preloadedContent through to calculateModelSwitching and trackEnhancedMetrics to avoid re-reads. 2. extension.ts: removed the hidden pre-warm of calculateUsageAnalysisStats that ran even when the analysis panel was not open. This triggered workspace customization scans and JSONL processing on every 5-minute timer tick, amplifying the event-loop starvation on startup. 3. extension.ts: replaced hand-rolled synchronous applyDelta loops in the session details and log viewer paths with reconstructJsonlStateAsync, a new helper in tokenEstimation.ts that yields to the event loop every 500 lines to prevent blocking. Co-authored-by: Rob Bos <rajbos@users.noreply.github.com>
1 parent 04099a6 commit 781a921

File tree

3 files changed

+86
-36
lines changed

3 files changed

+86
-36
lines changed

vscode-extension/src/extension.ts

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
calculateEstimatedCost as _calculateEstimatedCost,
6868
createEmptyContextRefs as _createEmptyContextRefs,
6969
getTotalTokensFromModelUsage as _getTotalTokensFromModelUsage,
70+
reconstructJsonlStateAsync as _reconstructJsonlStateAsync,
7071
} from './tokenEstimation';
7172
import { SessionDiscovery } from './sessionDiscovery';
7273
import { CacheManager } from './cacheManager';
@@ -1074,8 +1075,10 @@ class CopilotTokenTracker implements vscode.Disposable {
10741075
this.analysisPanel.webview.html = this.getUsageAnalysisHtml(this.analysisPanel.webview, analysisStats);
10751076
}
10761077
} else {
1077-
// Pre-populate the cache even when panel isn't open, so first open is fast
1078-
await this.calculateUsageAnalysisStats(false);
1078+
// Skip pre-warming usage analysis when the panel isn't open.
1079+
// calculateUsageAnalysisStats triggers workspace customization scans
1080+
// and JSONL reconstruction which can starve the extension host event loop
1081+
// on startup, amplifying the crash-loop risk.
10791082
}
10801083

10811084
// If the maturity panel is open, update its content.
@@ -2927,16 +2930,9 @@ class CopilotTokenTracker implements vscode.Disposable {
29272930
}
29282931

29292932
if (isDeltaBased) {
2930-
// Delta-based format: reconstruct full state first, then extract details
2931-
let sessionState: any = {};
2932-
for (const line of lines) {
2933-
try {
2934-
const delta = JSON.parse(line);
2935-
sessionState = this.applyDelta(sessionState, delta);
2936-
} catch {
2937-
// Skip invalid lines
2938-
}
2939-
}
2933+
// Delta-based format: reconstruct full state asynchronously to avoid
2934+
// blocking the extension host event loop on large files.
2935+
const { sessionState } = await _reconstructJsonlStateAsync(lines);
29402936

29412937
// Extract session metadata from reconstructed state
29422938
if (sessionState.creationDate) {
@@ -3443,16 +3439,9 @@ class CopilotTokenTracker implements vscode.Disposable {
34433439
}
34443440

34453441
if (isDeltaBased) {
3446-
// Delta-based format: reconstruct full state first, then extract turns
3447-
let sessionState: any = {};
3448-
for (const line of lines) {
3449-
try {
3450-
const delta = JSON.parse(line);
3451-
sessionState = this.applyDelta(sessionState, delta);
3452-
} catch {
3453-
// Skip invalid lines
3454-
}
3455-
}
3442+
// Delta-based format: reconstruct full state asynchronously to avoid
3443+
// blocking the extension host event loop on large files.
3444+
const { sessionState } = await _reconstructJsonlStateAsync(lines);
34563445

34573446
// Extract session-level info
34583447
let sessionMode: 'ask' | 'edit' | 'agent' | 'plan' | 'customAgent' = 'ask';

vscode-extension/src/tokenEstimation.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,33 @@ export function estimateTokensFromJsonlSession(fileContent: string): { tokens: n
150150
return { tokens: totalTokens + totalThinkingTokens, thinkingTokens: totalThinkingTokens, actualTokens: finalActualTokens };
151151
}
152152

153+
/**
154+
* Asynchronously reconstruct the full session state from delta-based JSONL lines.
155+
* Yields to the event loop every `yieldInterval` lines to prevent starving the
156+
* extension host's single-threaded event loop on large files.
157+
*/
158+
export async function reconstructJsonlStateAsync(lines: string[], yieldInterval = 500): Promise<{ sessionState: any; isDeltaBased: boolean }> {
159+
let sessionState: any = {};
160+
let isDeltaBased = false;
161+
for (let i = 0; i < lines.length; i++) {
162+
const line = lines[i];
163+
if (!line.trim()) { continue; }
164+
try {
165+
const delta = JSON.parse(line);
166+
if (typeof delta.kind === 'number') {
167+
isDeltaBased = true;
168+
sessionState = applyDelta(sessionState, delta);
169+
}
170+
} catch {
171+
// Skip invalid lines
172+
}
173+
if (isDeltaBased && i > 0 && i % yieldInterval === 0) {
174+
await new Promise<void>(resolve => setTimeout(resolve, 0));
175+
}
176+
}
177+
return { sessionState, isDeltaBased };
178+
}
179+
153180
/**
154181
* Extract per-request actual token usage from raw JSONL lines using regex.
155182
* Handles cases where lines with result data fail JSON.parse due to bad escape characters.

vscode-extension/src/usageAnalysis.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -556,11 +556,11 @@ function applyModelTierClassification(
556556
* Calculate model switching statistics for a session file.
557557
* This method updates the analysis.modelSwitching field in place.
558558
*/
559-
export async function calculateModelSwitching(deps: Pick<UsageAnalysisDeps, 'warn' | 'modelPricing' | 'openCode' | 'continue_' | 'tokenEstimators'>, sessionFile: string, analysis: SessionUsageAnalysis): Promise<void> {
559+
export async function calculateModelSwitching(deps: Pick<UsageAnalysisDeps, 'warn' | 'modelPricing' | 'openCode' | 'continue_' | 'tokenEstimators'>, sessionFile: string, analysis: SessionUsageAnalysis, preloadedContent?: string): Promise<void> {
560560
try {
561561
// Use non-cached method to avoid circular dependency
562562
// (getSessionFileDataCached -> analyzeSessionUsage -> getModelUsageFromSessionCached -> getSessionFileDataCached)
563-
const modelUsage = await getModelUsageFromSession(deps, sessionFile);
563+
const modelUsage = await getModelUsageFromSession(deps, sessionFile, preloadedContent);
564564
const modelCount = modelUsage ? Object.keys(modelUsage).length : 0;
565565

566566
// Skip if modelUsage is undefined or empty (not a valid session file)
@@ -593,7 +593,7 @@ export async function calculateModelSwitching(deps: Pick<UsageAnalysisDeps, 'war
593593
analysis.modelSwitching.hasMixedTiers = standardModels.length > 0 && premiumModels.length > 0;
594594

595595
// Count requests per tier and model switches by examining request sequence
596-
const fileContent = await fs.promises.readFile(sessionFile, 'utf8');
596+
const fileContent = preloadedContent ?? await fs.promises.readFile(sessionFile, 'utf8');
597597
// Check if this is a UUID-only file (new Copilot CLI format)
598598
if (isUuidPointerFile(fileContent)) {
599599
return;
@@ -719,9 +719,9 @@ export async function calculateModelSwitching(deps: Pick<UsageAnalysisDeps, 'war
719719
* - Conversation patterns (multi-turn sessions)
720720
* - Agent type usage
721721
*/
722-
export async function trackEnhancedMetrics(deps: Pick<UsageAnalysisDeps, 'warn'>, sessionFile: string, analysis: SessionUsageAnalysis): Promise<void> {
722+
export async function trackEnhancedMetrics(deps: Pick<UsageAnalysisDeps, 'warn'>, sessionFile: string, analysis: SessionUsageAnalysis, preloadedContent?: string): Promise<void> {
723723
try {
724-
const fileContent = await fs.promises.readFile(sessionFile, 'utf8');
724+
const fileContent = preloadedContent ?? await fs.promises.readFile(sessionFile, 'utf8');
725725

726726
// Check if this is a UUID-only file (new Copilot CLI format)
727727
if (isUuidPointerFile(fileContent)) {
@@ -1280,8 +1280,42 @@ export async function analyzeSessionUsage(deps: UsageAnalysisDeps, sessionFile:
12801280
}
12811281
}
12821282

1283-
// Calculate model switching for delta-based JSONL files
1284-
await calculateModelSwitching(deps, sessionFile, analysis);
1283+
// Compute model switching inline from the already-reconstructed state
1284+
// to avoid re-reading and re-parsing the file in calculateModelSwitching.
1285+
{
1286+
// Derive the session-level default model from reconstructed state,
1287+
// mirroring the selectedModel extraction used in the line-by-line path.
1288+
const sessionDefaultModel = (
1289+
sessionState.selectedModel?.identifier ||
1290+
sessionState.selectedModel?.metadata?.id ||
1291+
sessionState.inputState?.selectedModel?.metadata?.id ||
1292+
'gpt-4o'
1293+
).replace(/^copilot\//, '');
1294+
1295+
const models: string[] = [];
1296+
for (const req of requests) {
1297+
if (!req || !req.requestId) { continue; }
1298+
let reqModel = sessionDefaultModel;
1299+
if (req.modelId) {
1300+
reqModel = req.modelId.replace(/^copilot\//, '');
1301+
} else if (req.result?.metadata?.modelId) {
1302+
reqModel = req.result.metadata.modelId.replace(/^copilot\//, '');
1303+
} else if (req.result?.details) {
1304+
reqModel = getModelFromRequest(req, deps.modelPricing);
1305+
}
1306+
models.push(reqModel);
1307+
}
1308+
const uniqueModels = [...new Set(models)];
1309+
analysis.modelSwitching.uniqueModels = uniqueModels;
1310+
analysis.modelSwitching.modelCount = uniqueModels.length;
1311+
analysis.modelSwitching.totalRequests = models.length;
1312+
let switchCount = 0;
1313+
for (let mi = 1; mi < models.length; mi++) {
1314+
if (models[mi] !== models[mi - 1]) { switchCount++; }
1315+
}
1316+
analysis.modelSwitching.switchCount = switchCount;
1317+
applyModelTierClassification(deps, uniqueModels, models, analysis);
1318+
}
12851319

12861320
// Derive conversation patterns from mode usage before returning
12871321
deriveConversationPatterns(analysis);
@@ -1439,7 +1473,7 @@ export async function analyzeSessionUsage(deps: UsageAnalysisDeps, sessionFile:
14391473
}
14401474
}
14411475
// Calculate model switching for JSONL files before returning
1442-
await calculateModelSwitching(deps, sessionFile, analysis);
1476+
await calculateModelSwitching(deps, sessionFile, analysis, fileContent);
14431477

14441478
// Derive conversation patterns from mode usage before returning
14451479
deriveConversationPatterns(analysis);
@@ -1531,16 +1565,16 @@ export async function analyzeSessionUsage(deps: UsageAnalysisDeps, sessionFile:
15311565
}
15321566
}
15331567
}
1568+
1569+
// Calculate model switching statistics from session (pass preloaded content to avoid re-reading)
1570+
await calculateModelSwitching(deps, sessionFile, analysis, fileContent);
1571+
1572+
// Track new metrics: edit scope, apply usage, session duration, conversation patterns, agent types
1573+
await trackEnhancedMetrics(deps, sessionFile, analysis, fileContent);
15341574
} catch (error) {
15351575
deps.warn(`Error analyzing session usage from ${sessionFile}: ${error}`);
15361576
}
15371577

1538-
// Calculate model switching statistics from session
1539-
await calculateModelSwitching(deps, sessionFile, analysis);
1540-
1541-
// Track new metrics: edit scope, apply usage, session duration, conversation patterns, agent types
1542-
await trackEnhancedMetrics(deps, sessionFile, analysis);
1543-
15441578
return analysis;
15451579
}
15461580

0 commit comments

Comments
 (0)