Skip to content
Merged
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
87 changes: 87 additions & 0 deletions src/backend/services/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { DefaultAzureCredential } from '@azure/identity';
import { safeStringifyError } from '../../utils/errors';
Expand Down Expand Up @@ -94,6 +95,8 @@ export class SyncService {
private backendSyncInterval: NodeJS.Timeout | undefined;
private consecutiveFailures = 0;
private readonly MAX_CONSECUTIVE_FAILURES = 5;
/** Stale threshold for the sync lock file (matches the sync timer interval). */
private static readonly SYNC_LOCK_STALE_MS = BACKEND_SYNC_MIN_INTERVAL_MS;

constructor(
private readonly deps: SyncServiceDeps,
Expand All @@ -103,6 +106,82 @@ export class SyncService {
private readonly utility: typeof BackendUtility
) {}

// ── Cross-instance file lock ────────────────────────────────────────

/**
* Path for the sync lock file. Uses globalStorageUri which is already
* scoped per VS Code edition (stable vs insiders).
*/
private getSyncLockPath(): string | undefined {
const ctx = this.deps.context;
if (!ctx) { return undefined; }
return path.join(ctx.globalStorageUri.fsPath, 'backend_sync.lock');
}

/**
* Try to acquire an exclusive file lock so only one VS Code window
* can run a backend sync at a time.
*/
private async acquireSyncLock(): Promise<boolean> {
const lockPath = this.getSyncLockPath();
if (!lockPath) { return true; } // No context → allow (tests)
try {
await fs.promises.mkdir(path.dirname(lockPath), { recursive: true });
const fd = await fs.promises.open(lockPath, 'wx');
await fd.writeFile(JSON.stringify({
sessionId: vscode.env.sessionId,
timestamp: Date.now()
}));
await fd.close();
return true;
} catch (err: any) {
if (err.code !== 'EEXIST') {
this.deps.warn(`Sync lock: unexpected error acquiring lock: ${err.message}`);
return false;
}
// Lock file exists — check if stale
try {
const content = await fs.promises.readFile(lockPath, 'utf-8');
const lock = JSON.parse(content);
if (Date.now() - lock.timestamp > SyncService.SYNC_LOCK_STALE_MS) {
this.deps.log('Sync lock: breaking stale lock from another window');
await fs.promises.unlink(lockPath);
try {
const fd = await fs.promises.open(lockPath, 'wx');
await fd.writeFile(JSON.stringify({
sessionId: vscode.env.sessionId,
timestamp: Date.now()
}));
await fd.close();
return true;
} catch {
return false;
}
}
} catch {
// Lock file may have been deleted by its owner
}
return false;
}
}

/**
* Release the sync lock, but only if we own it.
*/
private async releaseSyncLock(): Promise<void> {
const lockPath = this.getSyncLockPath();
if (!lockPath) { return; }
try {
const content = await fs.promises.readFile(lockPath, 'utf-8');
const lock = JSON.parse(content);
if (lock.sessionId === vscode.env.sessionId) {
await fs.promises.unlink(lockPath);
}
} catch {
// Lock file already gone or unreadable
}
}

/**
* Start the background sync timer if backend is enabled.
* @param settings - Backend settings to check if sync should be enabled
Expand Down Expand Up @@ -707,6 +786,13 @@ export class SyncService {
return;
}

// Acquire cross-instance file lock to prevent concurrent syncs from multiple VS Code windows
const lockAcquired = await this.acquireSyncLock();
if (!lockAcquired) {
this.deps.log('Backend sync: skipping (another VS Code window is currently syncing)');
return;
}

this.backendSyncInProgress = true;
try {
this.deps.log('Backend sync: starting rollup sync');
Expand Down Expand Up @@ -858,6 +944,7 @@ export class SyncService {
this.deps.warn(`Backend sync: ${safeStringifyError(e, secretsToRedact)}`);
} finally {
this.backendSyncInProgress = false;
await this.releaseSyncLock();
}
});
return this.syncQueue;
Expand Down
183 changes: 173 additions & 10 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ interface WorkspaceCustomizationSummary {

class CopilotTokenTracker implements vscode.Disposable {
// Cache version - increment this when making changes that require cache invalidation
private static readonly CACHE_VERSION = 22; // Force cache rebuild for actualTokens extraction fix
private static readonly CACHE_VERSION = 23; // Cache key format changed: per-edition file lock instead of per-session keys
// Maximum length for displaying workspace IDs in diagnostics/customization matrix
private static readonly WORKSPACE_ID_DISPLAY_LENGTH = 8;

Expand Down Expand Up @@ -1165,23 +1165,120 @@ class CopilotTokenTracker implements vscode.Disposable {
}
}

/**
* Generate a cache identifier based on VS Code extension mode.
* VS Code editions (stable vs insiders) already have separate globalState storage,
* so we only need to distinguish between production and development (debug) mode.
*/
private getCacheIdentifier(): string {
return this.context.extensionMode === vscode.ExtensionMode.Development ? 'dev' : 'prod';
}

/**
* Get the path for the cache lock file.
* Uses globalStorageUri which is already scoped per VS Code edition.
*/
private getCacheLockPath(): string {
const cacheId = this.getCacheIdentifier();
return path.join(this.context.globalStorageUri.fsPath, `cache_${cacheId}.lock`);
}

/**
* Acquire an exclusive file lock for cache writes.
* Uses atomic file creation (O_EXCL / CREATE_NEW) to prevent concurrent writes
* across multiple VS Code windows of the same edition.
* Returns true if lock acquired, false if another instance holds it.
*/
private async acquireCacheLock(): Promise<boolean> {
const lockPath = this.getCacheLockPath();
try {
// Ensure the directory exists
await fs.promises.mkdir(path.dirname(lockPath), { recursive: true });

// Atomic exclusive create — fails if lock file already exists
const fd = await fs.promises.open(lockPath, 'wx');
await fd.writeFile(JSON.stringify({
sessionId: vscode.env.sessionId,
timestamp: Date.now()
}));
await fd.close();
return true;
} catch (err: any) {
if (err.code !== 'EEXIST') {
// Unexpected error (permissions, disk full, etc.)
this.warn(`Unexpected error acquiring cache lock: ${err.message}`);
return false;
}

// Lock file exists — check if it's stale (owner crashed)
try {
const content = await fs.promises.readFile(lockPath, 'utf-8');
const lock = JSON.parse(content);
const staleThreshold = 5 * 60 * 1000; // 5 minutes (matches update interval)

if (Date.now() - lock.timestamp > staleThreshold) {
// Stale lock — break it and retry once
this.log('Breaking stale cache lock');
await fs.promises.unlink(lockPath);
try {
const fd = await fs.promises.open(lockPath, 'wx');
await fd.writeFile(JSON.stringify({
sessionId: vscode.env.sessionId,
timestamp: Date.now()
}));
await fd.close();
return true;
} catch {
return false; // Another instance beat us to it
}
}
} catch {
// Can't read lock file — might have been deleted by the owner already
}
return false;
}
}

/**
* Release the cache lock file, but only if we own it.
*/
private async releaseCacheLock(): Promise<void> {
const lockPath = this.getCacheLockPath();
try {
const content = await fs.promises.readFile(lockPath, 'utf-8');
const lock = JSON.parse(content);
if (lock.sessionId === vscode.env.sessionId) {
await fs.promises.unlink(lockPath);
}
} catch {
// Lock file already gone or unreadable — nothing to do
}
}

// Persistent cache storage methods
private loadCacheFromStorage(): void {
try {
const cacheId = this.getCacheIdentifier();
const versionKey = `sessionFileCacheVersion_${cacheId}`;
const cacheKey = `sessionFileCache_${cacheId}`;

// One-time migration: clean up old per-session cache keys from previous versions
this.migrateOldCacheKeys(cacheId);

// Check cache version first
const storedVersion = this.context.globalState.get<number>('sessionFileCacheVersion');
const storedVersion = this.context.globalState.get<number>(versionKey);
if (storedVersion !== CopilotTokenTracker.CACHE_VERSION) {
this.log(`Cache version mismatch (stored: ${storedVersion}, current: ${CopilotTokenTracker.CACHE_VERSION}). Clearing cache.`);
this.log(`Cache version mismatch (stored: ${storedVersion}, current: ${CopilotTokenTracker.CACHE_VERSION}) for ${cacheId}. Clearing cache.`);
this.sessionFileCache = new Map();
return;
}

const cacheData = this.context.globalState.get<Record<string, SessionFileCache>>('sessionFileCache');
const cacheData = this.context.globalState.get<Record<string, SessionFileCache>>(cacheKey);
if (cacheData) {
this.sessionFileCache = new Map(Object.entries(cacheData));
this.log(`Loaded ${this.sessionFileCache.size} cached session files from storage`);
this.log(`Loaded ${this.sessionFileCache.size} cached session files from storage (${cacheId})`);
} else {
this.log('No cached session files found in storage');
this.log(`No cached session files found in storage for ${cacheId}`);
}
} catch (error) {
this.error('Error loading cache from storage:', error);
Expand All @@ -1190,15 +1287,76 @@ class CopilotTokenTracker implements vscode.Disposable {
}
}

/**
* One-time migration: remove old per-session cache keys that were created by
* earlier versions of the extension (keys containing sessionId or timestamp).
* Also removes the legacy unscoped keys ('sessionFileCache', 'sessionFileCacheVersion').
*/
private migrateOldCacheKeys(currentCacheId: string): void {
try {
const allKeys = this.context.globalState.keys();
const currentCacheKey = `sessionFileCache_${currentCacheId}`;
const currentVersionKey = `sessionFileCacheVersion_${currentCacheId}`;

let removedCount = 0;
for (const key of allKeys) {
// Remove old timestamp keys (no longer used)
if (key.startsWith('sessionFileCacheTimestamp_')) {
this.context.globalState.update(key, undefined);
removedCount++;
continue;
}
// Remove old per-session cache keys that have session IDs embedded
// (they contain more than one underscore-separated segment after the prefix)
if (key.startsWith('sessionFileCache_') && key !== currentCacheKey) {
const suffix = key.replace('sessionFileCache_', '');
if (suffix !== 'dev' && suffix !== 'prod') {
this.context.globalState.update(key, undefined);
removedCount++;
}
}
if (key.startsWith('sessionFileCacheVersion_') && key !== currentVersionKey) {
const suffix = key.replace('sessionFileCacheVersion_', '');
if (suffix !== 'dev' && suffix !== 'prod') {
this.context.globalState.update(key, undefined);
removedCount++;
}
}
// Remove legacy unscoped keys from the original code
if (key === 'sessionFileCache' || key === 'sessionFileCacheVersion') {
this.context.globalState.update(key, undefined);
removedCount++;
}
}

if (removedCount > 0) {
this.log(`Migrated: removed ${removedCount} old cache keys from globalState`);
}
} catch (error) {
this.error('Error migrating old cache keys:', error);
}
}

private async saveCacheToStorage(): Promise<void> {
const acquired = await this.acquireCacheLock();
if (!acquired) {
this.log('Cache lock held by another VS Code window, skipping save');
return;
}
try {
const cacheId = this.getCacheIdentifier();
const versionKey = `sessionFileCacheVersion_${cacheId}`;
const cacheKey = `sessionFileCache_${cacheId}`;

// Convert Map to plain object for storage
const cacheData = Object.fromEntries(this.sessionFileCache);
await this.context.globalState.update('sessionFileCache', cacheData);
await this.context.globalState.update('sessionFileCacheVersion', CopilotTokenTracker.CACHE_VERSION);
this.log(`Saved ${this.sessionFileCache.size} cached session files to storage (version ${CopilotTokenTracker.CACHE_VERSION})`);
await this.context.globalState.update(cacheKey, cacheData);
await this.context.globalState.update(versionKey, CopilotTokenTracker.CACHE_VERSION);
this.log(`Saved ${this.sessionFileCache.size} cached session files to storage (version ${CopilotTokenTracker.CACHE_VERSION}, ${cacheId})`);
} catch (error) {
this.error('Error saving cache to storage:', error);
} finally {
await this.releaseCacheLock();
}
}

Expand All @@ -1208,9 +1366,14 @@ class CopilotTokenTracker implements vscode.Disposable {
this.outputChannel.show(true);
this.log('Clearing session file cache...');

const cacheId = this.getCacheIdentifier();
const cacheKey = `sessionFileCache_${cacheId}`;
const versionKey = `sessionFileCacheVersion_${cacheId}`;

const cacheSize = this.sessionFileCache.size;
this.sessionFileCache.clear();
await this.context.globalState.update('sessionFileCache', undefined);
await this.context.globalState.update(cacheKey, undefined);
await this.context.globalState.update(versionKey, undefined);
// Reset diagnostics loaded flag so the diagnostics view will reload files
this.diagnosticsHasLoadedFiles = false;
this.diagnosticsCachedFiles = [];
Expand Down