Skip to content

Commit 2884db7

Browse files
committed
Fix race condition when downloading binaries from different windows
1 parent ebbbb9a commit 2884db7

File tree

3 files changed

+86
-28
lines changed

3 files changed

+86
-28
lines changed

src/core/cliManager.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import globalAxios, {
33
type AxiosRequestConfig,
44
} from "axios";
55
import { type Api } from "coder/site/src/api/api";
6-
import { createWriteStream, type WriteStream } from "fs";
7-
import fs from "fs/promises";
8-
import { type IncomingMessage } from "http";
9-
import path from "path";
6+
import { createWriteStream, type WriteStream } from "node:fs";
7+
import fs from "node:fs/promises";
8+
import { type IncomingMessage } from "node:http";
9+
import path from "node:path";
1010
import prettyBytes from "pretty-bytes";
1111
import * as semver from "semver";
1212
import * as vscode from "vscode";
@@ -99,26 +99,26 @@ export class CliManager {
9999

100100
// Remove any left-over old or temporary binaries and signatures.
101101
const removed = await cliUtils.rmOld(binPath);
102-
removed.forEach(({ fileName, error }) => {
102+
for (const { fileName, error, skipped } of removed) {
103103
if (error) {
104104
this.output.warn("Failed to remove", fileName, error);
105+
} else if (skipped) {
106+
this.output.debug("Skipped", fileName, "(file too new)");
105107
} else {
106108
this.output.info("Removed", fileName);
107109
}
108-
});
110+
}
109111

110112
// Figure out where to get the binary.
111113
const binName = cliUtils.name();
112-
const configSource = cfg.get("binarySource");
114+
const configSource = cfg.get<string>("binarySource") ?? "";
113115
const binSource =
114-
configSource && String(configSource).trim().length > 0
115-
? String(configSource)
116-
: "/bin/" + binName;
116+
configSource.trim().length > 0 ? configSource : "/bin/" + binName;
117117
this.output.info("Downloading binary from", binSource);
118118

119119
// Ideally we already caught that this was the right version and returned
120120
// early, but just in case set the ETag.
121-
const etag = stat !== undefined ? await cliUtils.eTag(binPath) : "";
121+
const etag = stat === undefined ? "" : await cliUtils.eTag(binPath);
122122
this.output.info("Using ETag", etag);
123123

124124
// Download the binary to a temporary file.

src/core/cliUtils.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
import { execFile, type ExecFileException } from "child_process";
2-
import * as crypto from "crypto";
3-
import { createReadStream, type Stats } from "fs";
4-
import fs from "fs/promises";
5-
import os from "os";
6-
import path from "path";
7-
import { promisify } from "util";
1+
import { execFile, type ExecFileException } from "node:child_process";
2+
import * as crypto from "node:crypto";
3+
import { createReadStream, type Stats } from "node:fs";
4+
import fs from "node:fs/promises";
5+
import os from "node:os";
6+
import path from "node:path";
7+
import { promisify } from "node:util";
8+
9+
/**
10+
* Minimum age in milliseconds before cleaning up temporary files.
11+
* This prevents deleting files that are actively being created by another window.
12+
*/
13+
const MIN_TEMP_FILE_AGE_MS = 30 * 60 * 1000;
814

915
/**
1016
* Stat the path or undefined if the path does not exist. Throw if unable to
@@ -60,7 +66,11 @@ export async function version(binPath: string): Promise<string> {
6066
return json.version;
6167
}
6268

63-
export type RemovalResult = { fileName: string; error: unknown };
69+
export type RemovalResult = {
70+
fileName: string;
71+
error: unknown;
72+
skipped?: boolean;
73+
};
6474

6575
/**
6676
* Remove binaries in the same directory as the specified path that have a
@@ -72,6 +82,7 @@ export async function rmOld(binPath: string): Promise<RemovalResult[]> {
7282
try {
7383
const files = await fs.readdir(binDir);
7484
const results: RemovalResult[] = [];
85+
const now = Date.now();
7586
for (const file of files) {
7687
const fileName = path.basename(file);
7788
if (
@@ -80,7 +91,17 @@ export async function rmOld(binPath: string): Promise<RemovalResult[]> {
8091
fileName.endsWith(".asc")
8192
) {
8293
try {
83-
await fs.rm(path.join(binDir, file), { force: true });
94+
const filePath = path.join(binDir, file);
95+
const stats = await fs.stat(filePath);
96+
const fileAge = now - stats.mtimeMs;
97+
98+
// Do not delete recently created files, they could be downloads in-progress
99+
if (fileAge < MIN_TEMP_FILE_AGE_MS) {
100+
results.push({ fileName, error: undefined, skipped: true });
101+
continue;
102+
}
103+
104+
await fs.rm(filePath, { force: true });
84105
results.push({ fileName, error: undefined });
85106
} catch (error) {
86107
results.push({ fileName, error });

test/unit/core/cliUtils.test.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import fs from "fs/promises";
2-
import os from "os";
3-
import path from "path";
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
44
import { beforeAll, describe, expect, it } from "vitest";
55

66
import * as cliUtils from "@/core/cliUtils";
@@ -95,17 +95,45 @@ describe("CliUtils", () => {
9595
expect(await cliUtils.rmOld(path.join(binDir, "bin1"))).toStrictEqual([]);
9696

9797
await fs.mkdir(binDir, { recursive: true });
98+
99+
// Create old files that should be removed.
100+
const oldTime = Date.now() - 60 * 60 * 1000; // 60 minutes ago
98101
await fs.writeFile(path.join(binDir, "bin.old-1"), "echo hello");
102+
await fs.utimes(
103+
path.join(binDir, "bin.old-1"),
104+
oldTime / 1000,
105+
oldTime / 1000,
106+
);
99107
await fs.writeFile(path.join(binDir, "bin.old-2"), "echo hello");
108+
await fs.utimes(
109+
path.join(binDir, "bin.old-2"),
110+
oldTime / 1000,
111+
oldTime / 1000,
112+
);
100113
await fs.writeFile(path.join(binDir, "bin.temp-1"), "echo hello");
101-
await fs.writeFile(path.join(binDir, "bin.temp-2"), "echo hello");
102-
await fs.writeFile(path.join(binDir, "bin1"), "echo hello");
103-
await fs.writeFile(path.join(binDir, "bin2"), "echo hello");
114+
await fs.utimes(
115+
path.join(binDir, "bin.temp-1"),
116+
oldTime / 1000,
117+
oldTime / 1000,
118+
);
104119
await fs.writeFile(path.join(binDir, "bin.asc"), "echo hello");
120+
await fs.utimes(
121+
path.join(binDir, "bin.asc"),
122+
oldTime / 1000,
123+
oldTime / 1000,
124+
);
125+
126+
// Create new files that should be skipped.
127+
await fs.writeFile(path.join(binDir, "bin.temp-2"), "echo hello");
105128
await fs.writeFile(path.join(binDir, "bin.old-1.asc"), "echo hello");
106129
await fs.writeFile(path.join(binDir, "bin.temp-2.asc"), "echo hello");
107130

108-
expect(await cliUtils.rmOld(path.join(binDir, "bin1"))).toStrictEqual([
131+
// Regular files that should never be removed.
132+
await fs.writeFile(path.join(binDir, "bin1"), "echo hello");
133+
await fs.writeFile(path.join(binDir, "bin2"), "echo hello");
134+
135+
const results = await cliUtils.rmOld(path.join(binDir, "bin1"));
136+
expect(results).toStrictEqual([
109137
{
110138
fileName: "bin.asc",
111139
error: undefined,
@@ -117,6 +145,7 @@ describe("CliUtils", () => {
117145
{
118146
fileName: "bin.old-1.asc",
119147
error: undefined,
148+
skipped: true,
120149
},
121150
{
122151
fileName: "bin.old-2",
@@ -129,14 +158,22 @@ describe("CliUtils", () => {
129158
{
130159
fileName: "bin.temp-2",
131160
error: undefined,
161+
skipped: true,
132162
},
133163
{
134164
fileName: "bin.temp-2.asc",
135165
error: undefined,
166+
skipped: true,
136167
},
137168
]);
138169

139-
expect(await fs.readdir(path.join(tmp, "bins"))).toStrictEqual([
170+
// Verify old files were removed and new files were kept.
171+
const remaining = await fs.readdir(path.join(tmp, "bins"));
172+
remaining.sort();
173+
expect(remaining).toStrictEqual([
174+
"bin.old-1.asc",
175+
"bin.temp-2",
176+
"bin.temp-2.asc",
140177
"bin1",
141178
"bin2",
142179
]);

0 commit comments

Comments
 (0)