Skip to content

Commit 9ef38a3

Browse files
authored
Fix race condition in concurrent CLI binary downloads (#656)
Implement file-based locking to prevent multiple VS Code windows from downloading the Coder CLI binary simultaneously. When one window is downloading, other windows now wait and display real-time progress from the active download. This prevents file corruption and download failures that could occur when opening multiple VS Code windows simultaneously. Closes #575
1 parent 118d50a commit 9ef38a3

File tree

12 files changed

+1154
-204
lines changed

12 files changed

+1154
-204
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- Fixed race condition when multiple VS Code windows download the Coder CLI binary simultaneously.
8+
Other windows now wait and display real-time progress instead of attempting concurrent downloads,
9+
preventing corruption and failures.
10+
511
## [v1.11.4](https://github.com/coder/vscode-coder/releases/tag/v1.11.4) 2025-11-20
612

713
### Fixed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@
351351
"jsonc-parser": "^3.3.1",
352352
"openpgp": "^6.2.2",
353353
"pretty-bytes": "^7.1.0",
354+
"proper-lockfile": "^4.1.2",
354355
"proxy-agent": "^6.5.0",
355356
"semver": "^7.7.3",
356357
"ua-parser-js": "1.0.40",
@@ -361,6 +362,7 @@
361362
"@types/eventsource": "^3.0.0",
362363
"@types/glob": "^7.1.3",
363364
"@types/node": "^22.14.1",
365+
"@types/proper-lockfile": "^4.1.4",
364366
"@types/semver": "^7.7.1",
365367
"@types/ua-parser-js": "0.7.36",
366368
"@types/vscode": "^1.73.0",

src/core/binaryLock.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import prettyBytes from "pretty-bytes";
2+
import * as lockfile from "proper-lockfile";
3+
import * as vscode from "vscode";
4+
5+
import { type Logger } from "../logging/logger";
6+
7+
import * as downloadProgress from "./downloadProgress";
8+
9+
/**
10+
* Timeout to detect stale lock files and take over from stuck processes.
11+
* This value is intentionally small so we can quickly takeover.
12+
*/
13+
const STALE_TIMEOUT_MS = 15000;
14+
15+
const LOCK_POLL_INTERVAL_MS = 500;
16+
17+
type LockRelease = () => Promise<void>;
18+
19+
/**
20+
* Manages file locking for binary downloads to coordinate between multiple
21+
* VS Code windows downloading the same binary.
22+
*/
23+
export class BinaryLock {
24+
constructor(
25+
private readonly vscodeProposed: typeof vscode,
26+
private readonly output: Logger,
27+
) {}
28+
29+
/**
30+
* Acquire the lock, or wait for another process if the lock is held.
31+
* Returns the lock release function and a flag indicating if we waited.
32+
*/
33+
async acquireLockOrWait(
34+
binPath: string,
35+
progressLogPath: string,
36+
): Promise<{ release: LockRelease; waited: boolean }> {
37+
const release = await this.safeAcquireLock(binPath);
38+
if (release) {
39+
return { release, waited: false };
40+
}
41+
42+
this.output.info(
43+
"Another process is downloading the binary, monitoring progress",
44+
);
45+
const newRelease = await this.monitorDownloadProgress(
46+
binPath,
47+
progressLogPath,
48+
);
49+
return { release: newRelease, waited: true };
50+
}
51+
52+
/**
53+
* Attempt to acquire a lock on the binary file.
54+
* Returns the release function if successful, null if lock is already held.
55+
*/
56+
private async safeAcquireLock(path: string): Promise<LockRelease | null> {
57+
try {
58+
const release = await lockfile.lock(path, {
59+
stale: STALE_TIMEOUT_MS,
60+
retries: 0,
61+
realpath: false,
62+
});
63+
return release;
64+
} catch (error) {
65+
if ((error as NodeJS.ErrnoException).code !== "ELOCKED") {
66+
throw error;
67+
}
68+
return null;
69+
}
70+
}
71+
72+
/**
73+
* Monitor download progress from another process by polling the progress log
74+
* and attempting to acquire the lock. Shows a VS Code progress notification.
75+
* Returns the lock release function once the download completes.
76+
*/
77+
private async monitorDownloadProgress(
78+
binPath: string,
79+
progressLogPath: string,
80+
): Promise<LockRelease> {
81+
return await this.vscodeProposed.window.withProgress(
82+
{
83+
location: vscode.ProgressLocation.Notification,
84+
title: "Another window is downloading the Coder CLI binary",
85+
cancellable: false,
86+
},
87+
async (progress) => {
88+
return new Promise<LockRelease>((resolve, reject) => {
89+
const poll = async () => {
90+
try {
91+
await this.updateProgressMonitor(progressLogPath, progress);
92+
const release = await this.safeAcquireLock(binPath);
93+
if (release) {
94+
return resolve(release);
95+
}
96+
// Schedule next poll only after current one completes
97+
setTimeout(poll, LOCK_POLL_INTERVAL_MS);
98+
} catch (error) {
99+
reject(error);
100+
}
101+
};
102+
poll().catch((error) => reject(error));
103+
});
104+
},
105+
);
106+
}
107+
108+
private async updateProgressMonitor(
109+
progressLogPath: string,
110+
progress: vscode.Progress<{ message?: string }>,
111+
): Promise<void> {
112+
const currentProgress =
113+
await downloadProgress.readProgress(progressLogPath);
114+
if (currentProgress) {
115+
const totalBytesPretty =
116+
currentProgress.totalBytes === null
117+
? "unknown"
118+
: prettyBytes(currentProgress.totalBytes);
119+
const message =
120+
currentProgress.status === "verifying"
121+
? "Verifying signature..."
122+
: `${prettyBytes(currentProgress.bytesDownloaded)} / ${totalBytesPretty}`;
123+
progress.report({ message });
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)