Skip to content

Commit 6cc9c20

Browse files
committed
feat: add database lockfile
Prevents conflicts when running multiple vscode instances Edit unit tests with the new feature Signed-off-by: BoxBoxJason <[email protected]>
1 parent d99f2ea commit 6cc9c20

File tree

12 files changed

+1222
-548
lines changed

12 files changed

+1222
-548
lines changed

package-lock.json

Lines changed: 45 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
"@tailwindcss/cli": "4.1.18",
131131
"@types/mocha": "10.0.10",
132132
"@types/node": "25.0.3",
133+
"@types/proper-lockfile": "^4.1.4",
133134
"@types/react": "19.2.7",
134135
"@types/react-dom": "19.2.3",
135136
"@types/sql.js": "^1.4.9",
@@ -147,6 +148,7 @@
147148
"typescript": "5.9.3"
148149
},
149150
"dependencies": {
151+
"proper-lockfile": "^4.1.2",
150152
"react": "19.2.3",
151153
"react-dom": "19.2.3",
152154
"sql.js": "^1.13.0"

src/database/lock.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* Database lock manager for multi-instance VS Code support
3+
*
4+
* This module handles file locking to prevent data corruption when multiple
5+
* VS Code instances access the same database file.
6+
*
7+
* @namespace db_lock
8+
* @author BoxBoxJason
9+
*/
10+
11+
import * as lockfile from "proper-lockfile";
12+
import * as fs from "node:fs";
13+
import * as path from "node:path";
14+
import * as os from "node:os";
15+
import * as vscode from "vscode";
16+
import logger from "../utils/logger";
17+
18+
// ================== MODULE VARIABLES ==================
19+
let lockRelease: (() => Promise<void>) | null = null;
20+
let isReadOnlyMode = false;
21+
let databasePath: string | null = null;
22+
23+
// Lock configuration
24+
const LOCK_OPTIONS: lockfile.LockOptions = {
25+
// Stale threshold: if lock file mtime is older than this, consider it stale
26+
// This handles ungraceful shutdowns where the lock wasn't released
27+
stale: 15000, // 15 seconds
28+
// How often to update the lock file mtime to prevent staleness
29+
update: 5000, // 5 seconds
30+
// Number of retries when acquiring lock
31+
retries: 0, // Don't retry, just go to readonly mode
32+
// Use realpath to resolve symlinks
33+
realpath: false, // Database file may not exist yet
34+
};
35+
36+
/**
37+
* Database lock manager namespace
38+
*
39+
* @namespace db_lock
40+
* @function acquireLock - Attempts to acquire the database lock
41+
* @function releaseLock - Releases the database lock
42+
* @function isReadOnly - Returns whether the extension is in readonly mode
43+
* @function getLockFilePath - Returns the lock file path for a database path
44+
*/
45+
export namespace db_lock {
46+
/**
47+
* Get the lock file path for a given database path.
48+
* Uses a cross-platform temporary directory approach for reliability.
49+
*
50+
* @param dbPath - The database file path
51+
* @returns The lock file path
52+
*/
53+
export function getLockFilePath(dbPath: string): string {
54+
// Use os.tmpdir() which works on all platforms (Linux, Windows, macOS)
55+
// and handles readOnlyRootFs scenarios better
56+
const tmpDir = os.tmpdir();
57+
// Create a unique but deterministic lock file name based on the database path
58+
const dbPathHash = Buffer.from(dbPath).toString("base64url");
59+
return path.join(tmpDir, `achievements-db-${dbPathHash}.lock`);
60+
}
61+
62+
/**
63+
* Attempts to acquire an exclusive lock on the database.
64+
* If the lock cannot be acquired, the extension will run in readonly mode.
65+
*
66+
* @param dbPath - The path to the database file
67+
* @returns Promise<boolean> - true if lock acquired (write mode), false if readonly
68+
*/
69+
export async function acquireLock(dbPath: string): Promise<boolean> {
70+
databasePath = dbPath;
71+
const lockPath = getLockFilePath(dbPath);
72+
73+
// Ensure the lock file directory exists
74+
const lockDir = path.dirname(lockPath);
75+
await fs.promises.mkdir(lockDir, { recursive: true });
76+
77+
// Create the lock file if it doesn't exist (proper-lockfile needs a file to lock)
78+
if (!fs.existsSync(lockPath)) {
79+
await fs.promises.writeFile(lockPath, "", { flag: "w" });
80+
}
81+
82+
try {
83+
logger.debug(`Attempting to acquire database lock at: ${lockPath}`);
84+
85+
lockRelease = await lockfile.lock(lockPath, {
86+
...LOCK_OPTIONS,
87+
onCompromised: (err) => {
88+
// Lock was compromised (e.g., another process removed it)
89+
logger.error(`Database lock was compromised: ${err.message}`);
90+
isReadOnlyMode = true;
91+
lockRelease = null;
92+
vscode.window.showWarningMessage(
93+
"Achievements: Database lock was lost. Switching to read-only mode."
94+
);
95+
},
96+
});
97+
98+
isReadOnlyMode = false;
99+
logger.info(
100+
"Database lock acquired successfully - running in write mode"
101+
);
102+
return true;
103+
} catch (err) {
104+
const error = err as Error;
105+
106+
if (error.message.includes("ELOCKED")) {
107+
// Another instance has the lock
108+
logger.warn(
109+
"Another VS Code instance has the database lock - running in read-only mode"
110+
);
111+
isReadOnlyMode = true;
112+
vscode.window.showWarningMessage(
113+
"Achievements: Another VS Code instance is using the database. " +
114+
"This instance will run in read-only mode (achievements won't be tracked)."
115+
);
116+
return false;
117+
}
118+
119+
// Some other error occurred
120+
logger.error(`Failed to acquire database lock: ${error.message}`);
121+
// Default to readonly mode to be safe
122+
isReadOnlyMode = true;
123+
vscode.window.showWarningMessage(
124+
"Achievements: Failed to acquire database lock. Running in read-only mode."
125+
);
126+
return false;
127+
}
128+
}
129+
130+
/**
131+
* Releases the database lock if it was acquired.
132+
*
133+
* @returns Promise<void>
134+
*/
135+
export async function releaseLock(): Promise<void> {
136+
if (lockRelease) {
137+
try {
138+
await lockRelease();
139+
logger.info("Database lock released successfully");
140+
} catch (err) {
141+
logger.error(
142+
`Failed to release database lock: ${(err as Error).message}`
143+
);
144+
} finally {
145+
lockRelease = null;
146+
}
147+
}
148+
149+
// Clean up the lock file
150+
if (databasePath) {
151+
const lockPath = getLockFilePath(databasePath);
152+
try {
153+
if (fs.existsSync(lockPath)) {
154+
await fs.promises.unlink(lockPath);
155+
logger.debug(`Lock file removed: ${lockPath}`);
156+
}
157+
} catch (err) {
158+
// Ignore cleanup errors - the file might already be removed
159+
logger.debug(
160+
`Could not remove lock file (may already be removed): ${
161+
(err as Error).message
162+
}`
163+
);
164+
}
165+
}
166+
167+
isReadOnlyMode = false;
168+
databasePath = null;
169+
}
170+
171+
/**
172+
* Returns whether the extension is running in read-only mode.
173+
*
174+
* In readonly mode:
175+
* - The database can be read but not written to
176+
* - Listeners should not be activated
177+
* - Achievements will not be tracked
178+
*
179+
* @returns boolean - true if in readonly mode
180+
*/
181+
export function isReadOnly(): boolean {
182+
return isReadOnlyMode;
183+
}
184+
185+
/**
186+
* Check if a database file is currently locked.
187+
* Useful for testing and diagnostics.
188+
*
189+
* @param dbPath - The database file path
190+
* @returns Promise<boolean> - true if locked
191+
*/
192+
export async function checkLock(dbPath: string): Promise<boolean> {
193+
const lockPath = getLockFilePath(dbPath);
194+
195+
if (!fs.existsSync(lockPath)) {
196+
return false;
197+
}
198+
199+
try {
200+
return await lockfile.check(lockPath, {
201+
stale: LOCK_OPTIONS.stale,
202+
realpath: LOCK_OPTIONS.realpath,
203+
});
204+
} catch {
205+
return false;
206+
}
207+
}
208+
209+
/**
210+
* Force reset the readonly state. Used primarily for testing.
211+
* @internal
212+
*/
213+
export function _resetState(): void {
214+
lockRelease = null;
215+
isReadOnlyMode = false;
216+
databasePath = null;
217+
}
218+
}

0 commit comments

Comments
 (0)