Skip to content

Commit a901966

Browse files
authored
Merge pull request #365 from rajbos/refactoring
Refactoring extension.ts
2 parents 9b06cac + c2c5dd4 commit a901966

File tree

9 files changed

+5503
-4955
lines changed

9 files changed

+5503
-4955
lines changed

src/cacheManager.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
/**
2+
* Session file cache management.
3+
* Handles persistent caching of parsed session data to avoid re-reading files.
4+
*/
5+
import * as vscode from 'vscode';
6+
import * as fs from 'fs';
7+
import * as path from 'path';
8+
import * as os from 'os';
9+
import type { SessionFileCache } from './types';
10+
11+
export interface CacheManagerDeps {
12+
log: (msg: string) => void;
13+
warn: (msg: string) => void;
14+
error: (msg: string) => void;
15+
}
16+
17+
export class CacheManager {
18+
private sessionFileCache: Map<string, SessionFileCache> = new Map();
19+
private readonly context: vscode.ExtensionContext;
20+
private readonly deps: CacheManagerDeps;
21+
private readonly cacheVersion: number;
22+
23+
constructor(context: vscode.ExtensionContext, deps: CacheManagerDeps, cacheVersion: number) {
24+
this.context = context;
25+
this.deps = deps;
26+
this.cacheVersion = cacheVersion;
27+
}
28+
29+
get cache(): Map<string, SessionFileCache> {
30+
return this.sessionFileCache;
31+
}
32+
33+
// Cache management methods
34+
/**
35+
* Checks if the cache is valid for a file by comparing mtime and size.
36+
* If the cache entry is missing size (old format), treat as invalid so it will be upgraded.
37+
*/
38+
isCacheValid(filePath: string, currentMtime: number, currentSize: number): boolean {
39+
const cached = this.sessionFileCache.get(filePath);
40+
if (!cached) {
41+
return false;
42+
}
43+
// If size is missing (old cache), treat as invalid so it will be upgraded
44+
if (typeof cached.size !== 'number') {
45+
return false;
46+
}
47+
return cached.mtime === currentMtime && cached.size === currentSize;
48+
}
49+
50+
getCachedSessionData(filePath: string): SessionFileCache | undefined {
51+
return this.sessionFileCache.get(filePath);
52+
}
53+
54+
/**
55+
* Sets the cache entry for a session file, including file size.
56+
*/
57+
setCachedSessionData(filePath: string, data: SessionFileCache, fileSize?: number): void {
58+
if (typeof fileSize === 'number') {
59+
data.size = fileSize;
60+
}
61+
this.sessionFileCache.set(filePath, data);
62+
63+
// Limit cache size to prevent memory issues (keep last 1000 files)
64+
// Only trigger cleanup when size exceeds limit by 100 to avoid frequent operations
65+
if (this.sessionFileCache.size > 1100) {
66+
// Remove 100 oldest entries to bring size back to 1000
67+
// Maps maintain insertion order, so the first entries are the oldest
68+
const keysToDelete: string[] = [];
69+
let count = 0;
70+
for (const key of this.sessionFileCache.keys()) {
71+
keysToDelete.push(key);
72+
count++;
73+
if (count >= 100) {
74+
break;
75+
}
76+
}
77+
for (const key of keysToDelete) {
78+
this.sessionFileCache.delete(key);
79+
}
80+
this.deps.log(`Cache size limit reached, removed ${keysToDelete.length} oldest entries. Current size: ${this.sessionFileCache.size}`);
81+
}
82+
}
83+
84+
clearExpiredCache(): void {
85+
// Remove cache entries for files that no longer exist
86+
const filesToCheck = Array.from(this.sessionFileCache.keys());
87+
for (const filePath of filesToCheck) {
88+
try {
89+
if (!fs.existsSync(filePath)) {
90+
this.sessionFileCache.delete(filePath);
91+
}
92+
} catch (error) {
93+
// File access error, remove from cache
94+
this.sessionFileCache.delete(filePath);
95+
}
96+
}
97+
}
98+
99+
/**
100+
* Generate a cache identifier based on VS Code extension mode.
101+
* VS Code editions (stable vs insiders) already have separate globalState storage,
102+
* so we only need to distinguish between production and development (debug) mode.
103+
* In development mode, each VS Code window gets a unique cache identifier using
104+
* the session ID, preventing the Extension Development Host from sharing/fighting
105+
* with the main dev window's cache.
106+
*/
107+
getCacheIdentifier(): string {
108+
if (this.context.extensionMode === vscode.ExtensionMode.Development) {
109+
// Use a short hash of the session ID to keep the key short but unique per window
110+
const sessionId = vscode.env.sessionId;
111+
const hash = sessionId.substring(0, 8);
112+
return `dev-${hash}`;
113+
}
114+
return 'prod';
115+
}
116+
117+
/**
118+
* Get the path for the cache lock file.
119+
* Uses globalStorageUri which is already scoped per VS Code edition.
120+
*/
121+
getCacheLockPath(): string {
122+
const cacheId = this.getCacheIdentifier();
123+
return path.join(this.context.globalStorageUri.fsPath, `cache_${cacheId}.lock`);
124+
}
125+
126+
/**
127+
* Acquire an exclusive file lock for cache writes.
128+
* Uses atomic file creation (O_EXCL / CREATE_NEW) to prevent concurrent writes
129+
* across multiple VS Code windows of the same edition.
130+
* Returns true if lock acquired, false if another instance holds it.
131+
*/
132+
async acquireCacheLock(): Promise<boolean> {
133+
const lockPath = this.getCacheLockPath();
134+
try {
135+
// Ensure the directory exists
136+
await fs.promises.mkdir(path.dirname(lockPath), { recursive: true });
137+
138+
// Atomic exclusive create — fails if lock file already exists
139+
const fd = await fs.promises.open(lockPath, 'wx');
140+
await fd.writeFile(JSON.stringify({
141+
sessionId: vscode.env.sessionId,
142+
timestamp: Date.now()
143+
}));
144+
await fd.close();
145+
return true;
146+
} catch (err: any) {
147+
if (err.code !== 'EEXIST') {
148+
// Unexpected error (permissions, disk full, etc.)
149+
this.deps.warn(`Unexpected error acquiring cache lock: ${err.message}`);
150+
return false;
151+
}
152+
153+
// Lock file exists — check if it's stale (owner crashed)
154+
try {
155+
const content = await fs.promises.readFile(lockPath, 'utf-8');
156+
const lock = JSON.parse(content);
157+
const staleThreshold = 5 * 60 * 1000; // 5 minutes (matches update interval)
158+
159+
if (Date.now() - lock.timestamp > staleThreshold) {
160+
// Stale lock — break it and retry once
161+
this.deps.log('Breaking stale cache lock');
162+
await fs.promises.unlink(lockPath);
163+
try {
164+
const fd = await fs.promises.open(lockPath, 'wx');
165+
await fd.writeFile(JSON.stringify({
166+
sessionId: vscode.env.sessionId,
167+
timestamp: Date.now()
168+
}));
169+
await fd.close();
170+
return true;
171+
} catch {
172+
return false; // Another instance beat us to it
173+
}
174+
}
175+
} catch {
176+
// Can't read lock file — might have been deleted by the owner already
177+
}
178+
return false;
179+
}
180+
}
181+
182+
/**
183+
* Release the cache lock file, but only if we own it.
184+
*/
185+
async releaseCacheLock(): Promise<void> {
186+
const lockPath = this.getCacheLockPath();
187+
try {
188+
const content = await fs.promises.readFile(lockPath, 'utf-8');
189+
const lock = JSON.parse(content);
190+
if (lock.sessionId === vscode.env.sessionId) {
191+
await fs.promises.unlink(lockPath);
192+
}
193+
} catch {
194+
// Lock file already gone or unreadable — nothing to do
195+
}
196+
}
197+
198+
// Persistent cache storage methods
199+
loadCacheFromStorage(): void {
200+
try {
201+
const cacheId = this.getCacheIdentifier();
202+
const versionKey = `sessionFileCacheVersion_${cacheId}`;
203+
const cacheKey = `sessionFileCache_${cacheId}`;
204+
205+
// One-time migration: clean up old per-session cache keys from previous versions
206+
this.migrateOldCacheKeys(cacheId);
207+
208+
// Check cache version first
209+
const storedVersion = this.context.globalState.get<number>(versionKey);
210+
if (storedVersion !== this.cacheVersion) {
211+
this.deps.log(`Cache version mismatch (stored: ${storedVersion}, current: ${this.cacheVersion}) for ${cacheId}. Clearing cache.`);
212+
this.sessionFileCache = new Map();
213+
return;
214+
}
215+
216+
const cacheData = this.context.globalState.get<Record<string, SessionFileCache>>(cacheKey);
217+
if (cacheData) {
218+
this.sessionFileCache = new Map(Object.entries(cacheData));
219+
this.deps.log(`Loaded ${this.sessionFileCache.size} cached session files from storage (${cacheId})`);
220+
} else {
221+
this.deps.log(`No cached session files found in storage for ${cacheId}`);
222+
}
223+
} catch (error) {
224+
this.deps.error(`Error loading cache from storage: ${error}`);
225+
// Start with empty cache on error
226+
this.sessionFileCache = new Map();
227+
}
228+
}
229+
230+
/**
231+
* One-time migration: remove old per-session cache keys that were created by
232+
* earlier versions of the extension (keys containing sessionId or timestamp).
233+
* Also removes the legacy unscoped keys ('sessionFileCache', 'sessionFileCacheVersion').
234+
*/
235+
migrateOldCacheKeys(currentCacheId: string): void {
236+
try {
237+
const allKeys = this.context.globalState.keys();
238+
const currentCacheKey = `sessionFileCache_${currentCacheId}`;
239+
const currentVersionKey = `sessionFileCacheVersion_${currentCacheId}`;
240+
241+
let removedCount = 0;
242+
for (const key of allKeys) {
243+
// Remove old timestamp keys (no longer used)
244+
if (key.startsWith('sessionFileCacheTimestamp_')) {
245+
this.context.globalState.update(key, undefined);
246+
removedCount++;
247+
continue;
248+
}
249+
// Remove old per-session cache keys that have session IDs embedded
250+
// (they contain more than one underscore-separated segment after the prefix)
251+
if (key.startsWith('sessionFileCache_') && key !== currentCacheKey) {
252+
const suffix = key.replace('sessionFileCache_', '');
253+
if (suffix !== 'dev' && suffix !== 'prod') {
254+
this.context.globalState.update(key, undefined);
255+
removedCount++;
256+
}
257+
}
258+
if (key.startsWith('sessionFileCacheVersion_') && key !== currentVersionKey) {
259+
const suffix = key.replace('sessionFileCacheVersion_', '');
260+
if (suffix !== 'dev' && suffix !== 'prod') {
261+
this.context.globalState.update(key, undefined);
262+
removedCount++;
263+
}
264+
}
265+
// Remove legacy unscoped keys from the original code
266+
if (key === 'sessionFileCache' || key === 'sessionFileCacheVersion') {
267+
this.context.globalState.update(key, undefined);
268+
removedCount++;
269+
}
270+
}
271+
272+
if (removedCount > 0) {
273+
this.deps.log(`Migrated: removed ${removedCount} old cache keys from globalState`);
274+
}
275+
} catch (error) {
276+
this.deps.error(`Error migrating old cache keys: ${error}`);
277+
}
278+
}
279+
280+
async saveCacheToStorage(): Promise<void> {
281+
const acquired = await this.acquireCacheLock();
282+
if (!acquired) {
283+
this.deps.log('Cache lock held by another VS Code window, skipping save');
284+
return;
285+
}
286+
try {
287+
const cacheId = this.getCacheIdentifier();
288+
const versionKey = `sessionFileCacheVersion_${cacheId}`;
289+
const cacheKey = `sessionFileCache_${cacheId}`;
290+
291+
// Convert Map to plain object for storage
292+
const cacheData = Object.fromEntries(this.sessionFileCache);
293+
await this.context.globalState.update(cacheKey, cacheData);
294+
await this.context.globalState.update(versionKey, this.cacheVersion);
295+
this.deps.log(`Saved ${this.sessionFileCache.size} cached session files to storage (version ${this.cacheVersion}, ${cacheId})`);
296+
} catch (error) {
297+
this.deps.error(`Error saving cache to storage: ${error}`);
298+
} finally {
299+
await this.releaseCacheLock();
300+
}
301+
}
302+
}

0 commit comments

Comments
 (0)