Skip to content

Commit 0721125

Browse files
rajbosCopilot
andauthored
fix: convert sync I/O to async in session discovery to prevent VS Code UI freeze (#527)
* fix: convert sync I/O to async in getCopilotSessionFiles to prevent VS Code UI freeze All filesystem operations in getCopilotSessionFiles() and scanDirectoryForSessionFiles() were synchronous (fs.existsSync, fs.readdirSync, fs.statSync), blocking Node.js's event loop during startup. With many workspaces/sessions (300+ is common), this caused VS Code to become completely unresponsive — extensions could not load, git would not start, and the status bar was permanently stuck on 'Loading...'. Changes: - Add private pathExists() async helper using fs.promises.access() - Convert getCopilotSessionFiles() to use fs.promises.readdir/stat/access throughout (workspaceStorage, globalStorage, Copilot CLI, OpenCode, Crush scan loops) - Make scanDirectoryForSessionFiles() async (fs.promises.readdir/stat) - Replace inline fs.existsSync in log messages with deferred path checks - Copilot CLI subdirectory loop now uses fs.promises.stat directly (combining the existsSync + statSync into a single async stat call) These async I/O calls yield the event loop between operations, allowing VS Code's UI and other extensions to run during the filesystem scan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: move Icon before Tags in vsixmanifest to fix schema validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1c195bb commit 0721125

File tree

2 files changed

+42
-34
lines changed

2 files changed

+42
-34
lines changed

visualstudio-extension/src/CopilotTokenTracker/source.extension.vsixmanifest

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
Publisher="Rob Bos" />
1010
<DisplayName>AI Engineering Fluency</DisplayName>
1111
<Description>Measure and grow your AI engineering fluency in Visual Studio. Tracks GitHub Copilot token usage, today's and last-30-days activity, per-model breakdowns, and detailed session analysis.</Description>
12-
<Tags>GitHub Copilot, token usage, AI, productivity</Tags>
1312
<Icon>assets\logo.png</Icon>
13+
<Tags>GitHub Copilot, token usage, AI, productivity</Tags>
1414
</Metadata>
1515

1616
<Installation>

vscode-extension/src/sessionDiscovery.ts

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ export class SessionDiscovery {
3737
this._sessionFilesCacheTime = 0;
3838
}
3939

40+
/** Async replacement for fs.existsSync — does not block the event loop. */
41+
private async pathExists(p: string): Promise<boolean> {
42+
try {
43+
await fs.promises.access(p);
44+
return true;
45+
} catch {
46+
return false;
47+
}
48+
}
49+
4050
/**
4151
* Get all possible VS Code user data paths for all VS Code variants
4252
* Supports: Code (stable), Code - Insiders, VSCodium, remote servers, etc.
@@ -232,7 +242,7 @@ export class SessionDiscovery {
232242
for (let i = 0; i < allVSCodePaths.length; i++) {
233243
const codeUserPath = allVSCodePaths[i];
234244
try {
235-
if (fs.existsSync(codeUserPath)) {
245+
if (await this.pathExists(codeUserPath)) {
236246
foundPaths.push(codeUserPath);
237247
}
238248
} catch (checkError) {
@@ -258,16 +268,16 @@ export class SessionDiscovery {
258268
// Workspace storage sessions
259269
const workspaceStoragePath = path.join(codeUserPath, 'workspaceStorage');
260270
try {
261-
if (fs.existsSync(workspaceStoragePath)) {
271+
if (await this.pathExists(workspaceStoragePath)) {
262272
try {
263-
const workspaceDirs = fs.readdirSync(workspaceStoragePath);
273+
const workspaceDirs = await fs.promises.readdir(workspaceStoragePath);
264274

265275
for (const workspaceDir of workspaceDirs) {
266276
const chatSessionsPath = path.join(workspaceStoragePath, workspaceDir, 'chatSessions');
267277
try {
268-
if (fs.existsSync(chatSessionsPath)) {
278+
if (await this.pathExists(chatSessionsPath)) {
269279
try {
270-
const sessionFiles2 = fs.readdirSync(chatSessionsPath)
280+
const sessionFiles2 = (await fs.promises.readdir(chatSessionsPath))
271281
.filter(file => file.endsWith('.json') || file.endsWith('.jsonl'))
272282
.map(file => path.join(chatSessionsPath, file));
273283
if (sessionFiles2.length > 0) {
@@ -293,9 +303,9 @@ export class SessionDiscovery {
293303
// Global storage sessions (legacy emptyWindowChatSessions)
294304
const globalStoragePath = path.join(codeUserPath, 'globalStorage', 'emptyWindowChatSessions');
295305
try {
296-
if (fs.existsSync(globalStoragePath)) {
306+
if (await this.pathExists(globalStoragePath)) {
297307
try {
298-
const globalSessionFiles = fs.readdirSync(globalStoragePath)
308+
const globalSessionFiles = (await fs.promises.readdir(globalStoragePath))
299309
.filter(file => file.endsWith('.json') || file.endsWith('.jsonl'))
300310
.map(file => path.join(globalStoragePath, file));
301311
if (globalSessionFiles.length > 0) {
@@ -313,9 +323,9 @@ export class SessionDiscovery {
313323
// GitHub Copilot Chat extension global storage
314324
const copilotChatGlobalPath = path.join(codeUserPath, 'globalStorage', 'github.copilot-chat');
315325
try {
316-
if (fs.existsSync(copilotChatGlobalPath)) {
326+
if (await this.pathExists(copilotChatGlobalPath)) {
317327
this.deps.log(`📄 Scanning ${pathName}/globalStorage/github.copilot-chat`);
318-
this.scanDirectoryForSessionFiles(copilotChatGlobalPath, sessionFiles);
328+
await this.scanDirectoryForSessionFiles(copilotChatGlobalPath, sessionFiles);
319329
}
320330
} catch (checkError) {
321331
this.deps.warn(`Could not check Copilot Chat global storage path ${copilotChatGlobalPath}: ${checkError}`);
@@ -324,11 +334,11 @@ export class SessionDiscovery {
324334

325335
// Check for Copilot CLI session-state directory (new location for agent mode sessions)
326336
const copilotCliSessionPath = path.join(os.homedir(), '.copilot', 'session-state');
327-
this.deps.log(`📁 Checking Copilot CLI path: ${copilotCliSessionPath} (exists: ${fs.existsSync(copilotCliSessionPath)})`);
337+
this.deps.log(`📁 Checking Copilot CLI path: ${copilotCliSessionPath}`);
328338
try {
329-
if (fs.existsSync(copilotCliSessionPath)) {
339+
if (await this.pathExists(copilotCliSessionPath)) {
330340
try {
331-
const entries = fs.readdirSync(copilotCliSessionPath, { withFileTypes: true });
341+
const entries = await fs.promises.readdir(copilotCliSessionPath, { withFileTypes: true });
332342

333343
// Collect flat .json/.jsonl files at the top level
334344
const cliSessionFiles = entries
@@ -345,12 +355,10 @@ export class SessionDiscovery {
345355
for (const subDir of subDirs) {
346356
const eventsFile = path.join(copilotCliSessionPath, subDir.name, 'events.jsonl');
347357
try {
348-
if (fs.existsSync(eventsFile)) {
349-
const stats = fs.statSync(eventsFile);
350-
if (stats.size > 0) {
351-
sessionFiles.push(eventsFile);
352-
subDirSessionCount++;
353-
}
358+
const stats = await fs.promises.stat(eventsFile);
359+
if (stats.size > 0) {
360+
sessionFiles.push(eventsFile);
361+
subDirSessionCount++;
354362
}
355363
} catch {
356364
// Ignore individual file access errors
@@ -372,20 +380,20 @@ export class SessionDiscovery {
372380
const openCodeDataDir = this.deps.openCode.getOpenCodeDataDir();
373381
const openCodeSessionDir = path.join(openCodeDataDir, 'storage', 'session');
374382
const openCodeDbPath = path.join(openCodeDataDir, 'opencode.db');
375-
this.deps.log(`📁 Checking OpenCode JSON path: ${openCodeSessionDir} (exists: ${fs.existsSync(openCodeSessionDir)})`);
376-
this.deps.log(`📁 Checking OpenCode DB path: ${openCodeDbPath} (exists: ${fs.existsSync(openCodeDbPath)})`);
383+
this.deps.log(`📁 Checking OpenCode JSON path: ${openCodeSessionDir}`);
384+
this.deps.log(`📁 Checking OpenCode DB path: ${openCodeDbPath}`);
377385
try {
378-
if (fs.existsSync(openCodeSessionDir)) {
379-
const scanOpenCodeDir = (dir: string) => {
386+
if (await this.pathExists(openCodeSessionDir)) {
387+
const scanOpenCodeDir = async (dir: string) => {
380388
try {
381-
const entries = fs.readdirSync(dir, { withFileTypes: true });
389+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
382390
for (const entry of entries) {
383391
if (entry.isDirectory()) {
384-
scanOpenCodeDir(path.join(dir, entry.name));
392+
await scanOpenCodeDir(path.join(dir, entry.name));
385393
} else if (entry.name.startsWith('ses_') && entry.name.endsWith('.json')) {
386394
const fullPath = path.join(dir, entry.name);
387395
try {
388-
const stats = fs.statSync(fullPath);
396+
const stats = await fs.promises.stat(fullPath);
389397
if (stats.size > 0) {
390398
sessionFiles.push(fullPath);
391399
}
@@ -398,7 +406,7 @@ export class SessionDiscovery {
398406
// Ignore directory access errors
399407
}
400408
};
401-
scanOpenCodeDir(openCodeSessionDir);
409+
await scanOpenCodeDir(openCodeSessionDir);
402410
const openCodeCount = sessionFiles.length - (sessionFiles.filter(f => !this.deps.openCode.isOpenCodeSessionFile(f))).length;
403411
if (openCodeCount > 0) {
404412
this.deps.log(`📄 Found ${openCodeCount} session files in OpenCode storage`);
@@ -411,7 +419,7 @@ export class SessionDiscovery {
411419
// Check for OpenCode sessions in SQLite database (opencode.db)
412420
// Newer OpenCode versions store sessions in SQLite instead of JSON files
413421
try {
414-
if (fs.existsSync(openCodeDbPath)) {
422+
if (await this.pathExists(openCodeDbPath)) {
415423
const existingSessionIds = new Set(
416424
sessionFiles
417425
.filter(f => this.deps.openCode.isOpenCodeSessionFile(f))
@@ -443,9 +451,9 @@ export class SessionDiscovery {
443451
let crushTotal = 0;
444452
for (const project of crushProjects) {
445453
const dbPath = path.join(project.data_dir, 'crush.db');
446-
this.deps.log(`📁 Checking Crush DB path: ${dbPath} (exists: ${fs.existsSync(dbPath)})`);
454+
this.deps.log(`📁 Checking Crush DB path: ${dbPath}`);
447455
try {
448-
if (fs.existsSync(dbPath)) {
456+
if (await this.pathExists(dbPath)) {
449457
const sessionIds = await this.deps.crush.discoverSessionsInDb(dbPath);
450458
for (const sessionId of sessionIds) {
451459
// Virtual path: <data_dir>/crush.db#<uuid>
@@ -517,21 +525,21 @@ export class SessionDiscovery {
517525
*
518526
* NOTE: Mirrors logic in .github/skills/copilot-log-analysis/session-file-discovery.js
519527
*/
520-
scanDirectoryForSessionFiles(dir: string, sessionFiles: string[]): void {
528+
async scanDirectoryForSessionFiles(dir: string, sessionFiles: string[]): Promise<void> {
521529
try {
522-
const entries = fs.readdirSync(dir, { withFileTypes: true });
530+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
523531
for (const entry of entries) {
524532
const fullPath = path.join(dir, entry.name);
525533
if (entry.isDirectory()) {
526-
this.scanDirectoryForSessionFiles(fullPath, sessionFiles);
534+
await this.scanDirectoryForSessionFiles(fullPath, sessionFiles);
527535
} else if (entry.name.endsWith('.json') || entry.name.endsWith('.jsonl')) {
528536
// Skip known non-session files (embeddings, indexes, etc.)
529537
if (this.isNonSessionFile(entry.name)) {
530538
continue;
531539
}
532540
// Only add files that look like session files (have reasonable content)
533541
try {
534-
const stats = fs.statSync(fullPath);
542+
const stats = await fs.promises.stat(fullPath);
535543
if (stats.size > 0) {
536544
sessionFiles.push(fullPath);
537545
}

0 commit comments

Comments
 (0)