Skip to content

Commit 8a3d179

Browse files
committed
Handle concurrent binary downloads using file locks
1 parent 118d50a commit 8a3d179

File tree

11 files changed

+1100
-204
lines changed

11 files changed

+1100
-204
lines changed

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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
* Manages file locking for binary downloads to coordinate between multiple
11+
* VS Code windows downloading the same binary.
12+
*/
13+
export class BinaryLock {
14+
constructor(
15+
private readonly vscodeProposed: typeof vscode,
16+
private readonly output: Logger,
17+
) {}
18+
19+
/**
20+
* Acquire the lock, or wait for another process if the lock is held.
21+
* Returns the lock release function and a flag indicating if we waited.
22+
*/
23+
async acquireLockOrWait(
24+
binPath: string,
25+
progressLogPath: string,
26+
): Promise<{ release: () => Promise<void>; waited: boolean }> {
27+
const release = await this.safeAcquireLock(binPath);
28+
if (release) {
29+
return { release, waited: false };
30+
}
31+
32+
this.output.info(
33+
"Another process is downloading the binary, monitoring progress",
34+
);
35+
const newRelease = await this.monitorDownloadProgress(
36+
binPath,
37+
progressLogPath,
38+
);
39+
return { release: newRelease, waited: true };
40+
}
41+
42+
/**
43+
* Attempt to acquire a lock on the binary file.
44+
* Returns the release function if successful, null if lock is already held.
45+
*/
46+
private async safeAcquireLock(
47+
path: string,
48+
): Promise<(() => Promise<void>) | null> {
49+
try {
50+
const release = await lockfile.lock(path, {
51+
stale: downloadProgress.STALE_TIMEOUT_MS,
52+
retries: 0,
53+
realpath: false,
54+
});
55+
return release;
56+
} catch (error) {
57+
if ((error as NodeJS.ErrnoException).code !== "ELOCKED") {
58+
throw error;
59+
}
60+
return null;
61+
}
62+
}
63+
64+
/**
65+
* Monitor download progress from another process by polling the progress log
66+
* and attempting to acquire the lock. Shows a VS Code progress notification.
67+
* Returns the lock release function once the download completes.
68+
*/
69+
private async monitorDownloadProgress(
70+
binPath: string,
71+
progressLogPath: string,
72+
): Promise<() => Promise<void>> {
73+
return await this.vscodeProposed.window.withProgress(
74+
{
75+
location: vscode.ProgressLocation.Notification,
76+
title: "Another window is downloading the Coder CLI binary",
77+
cancellable: false,
78+
},
79+
async (progress) => {
80+
return new Promise<() => Promise<void>>((resolve, reject) => {
81+
const interval = setInterval(async () => {
82+
try {
83+
const currentProgress =
84+
await downloadProgress.readProgress(progressLogPath);
85+
if (currentProgress) {
86+
const totalBytesPretty =
87+
currentProgress.totalBytes === null
88+
? "unknown"
89+
: prettyBytes(currentProgress.totalBytes);
90+
const message =
91+
currentProgress.status === "verifying"
92+
? "Verifying signature..."
93+
: `${prettyBytes(currentProgress.bytesDownloaded)} / ${totalBytesPretty}`;
94+
progress.report({ message });
95+
}
96+
97+
const release = await this.safeAcquireLock(binPath);
98+
if (release) {
99+
clearInterval(interval);
100+
this.output.debug("Download completed by another process");
101+
return resolve(release);
102+
}
103+
} catch (error) {
104+
clearInterval(interval);
105+
reject(error);
106+
}
107+
}, 500);
108+
});
109+
},
110+
);
111+
}
112+
}

0 commit comments

Comments
 (0)