Skip to content

Commit f3bd107

Browse files
Clement OhClement Oh
authored andcommitted
fix: serialize shared Vite+ installs
1 parent 5af416e commit f3bd107

5 files changed

Lines changed: 291 additions & 70 deletions

File tree

dist/index.mjs

Lines changed: 65 additions & 65 deletions
Large diffs are not rendered by default.

src/install-lock.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { setTimeout as sleep } from "node:timers/promises";
6+
import { withVitePlusInstallLock } from "./install-lock.js";
7+
8+
describe("withVitePlusInstallLock", () => {
9+
it("serializes concurrent installs that share a Vite+ home", async () => {
10+
const root = await mkdtemp(join(tmpdir(), "setup-vp-lock-"));
11+
let active = 0;
12+
let maxActive = 0;
13+
14+
try {
15+
await Promise.all(
16+
Array.from({ length: 3 }, () =>
17+
withVitePlusInstallLock(join(root, ".vite-plus"), async () => {
18+
active++;
19+
maxActive = Math.max(maxActive, active);
20+
await sleep(10);
21+
active--;
22+
}),
23+
),
24+
);
25+
26+
expect(maxActive).toBe(1);
27+
} finally {
28+
await rm(root, { recursive: true, force: true });
29+
}
30+
});
31+
32+
it("recovers a stale lock that was interrupted before owner metadata existed", async () => {
33+
const root = await mkdtemp(join(tmpdir(), "setup-vp-lock-"));
34+
const vitePlusHome = join(root, ".vite-plus");
35+
const lockPath = `${vitePlusHome}.setup-vp-lock`;
36+
const old = new Date(Date.now() - 31 * 60 * 1000);
37+
38+
try {
39+
await mkdir(lockPath, { recursive: true });
40+
await utimes(lockPath, old, old);
41+
42+
await expect(withVitePlusInstallLock(vitePlusHome, async () => "recovered")).resolves.toBe(
43+
"recovered",
44+
);
45+
} finally {
46+
await rm(root, { recursive: true, force: true });
47+
}
48+
});
49+
});

src/install-lock.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2+
import { dirname, join } from "node:path";
3+
import { setTimeout as sleep } from "node:timers/promises";
4+
5+
const RETRY_DELAY_MS = 250;
6+
const STALE_LOCK_MS = 30 * 60 * 1000;
7+
8+
interface LockMetadata {
9+
createdAt: number;
10+
pid: number;
11+
}
12+
13+
/**
14+
* Serialize changes to Vite+'s process-wide installation. Self-hosted runners
15+
* can execute multiple jobs under one HOME, while Vite+ updates its `current`
16+
* shim and version directories in place.
17+
*/
18+
export async function withVitePlusInstallLock<T>(
19+
vitePlusHome: string,
20+
task: () => Promise<T>,
21+
): Promise<T> {
22+
const lockPath = `${vitePlusHome}.setup-vp-lock`;
23+
24+
await acquireLock(lockPath);
25+
try {
26+
return await task();
27+
} finally {
28+
await rm(lockPath, { recursive: true, force: true });
29+
}
30+
}
31+
32+
async function acquireLock(lockPath: string): Promise<void> {
33+
await mkdir(dirname(lockPath), { recursive: true });
34+
35+
for (;;) {
36+
try {
37+
await mkdir(lockPath);
38+
await writeFile(
39+
join(lockPath, "owner.json"),
40+
JSON.stringify({ createdAt: Date.now(), pid: process.pid } satisfies LockMetadata),
41+
);
42+
return;
43+
} catch (error) {
44+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
45+
await removeStaleLock(lockPath);
46+
await sleep(RETRY_DELAY_MS);
47+
}
48+
}
49+
}
50+
51+
async function removeStaleLock(lockPath: string): Promise<void> {
52+
try {
53+
const contents = await readFile(join(lockPath, "owner.json"), "utf8");
54+
const metadata = JSON.parse(contents) as Partial<LockMetadata>;
55+
await removeWhenExpired(lockPath, metadata.createdAt);
56+
} catch {
57+
// A process can be interrupted between mkdir and writing owner.json. The
58+
// directory timestamp gives that partial lock the same recovery path.
59+
await removeWhenExpired(lockPath);
60+
}
61+
}
62+
63+
async function removeWhenExpired(lockPath: string, createdAt?: number): Promise<void> {
64+
const lockAgeStart = createdAt ?? (await stat(lockPath)).mtimeMs;
65+
if (Date.now() - lockAgeStart > STALE_LOCK_MS) {
66+
await rm(lockPath, { recursive: true, force: true });
67+
}
68+
}

src/install-viteplus.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
2-
import { exec } from "@actions/exec";
2+
import { exec, getExecOutput } from "@actions/exec";
33
import { addPath, warning } from "@actions/core";
4-
import { writeFileSync } from "node:fs";
4+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
5+
import { mkdtempSync } from "node:fs";
6+
import { tmpdir } from "node:os";
7+
import { join } from "node:path";
58
import { installVitePlus } from "./install-viteplus.js";
69
import type { Inputs } from "./types.js";
710

@@ -13,6 +16,11 @@ vi.mock("@actions/core", () => ({
1316

1417
vi.mock("@actions/exec", () => ({
1518
exec: vi.fn(),
19+
getExecOutput: vi.fn(),
20+
}));
21+
22+
vi.mock("./install-lock.js", () => ({
23+
withVitePlusInstallLock: async <T>(_home: string, task: () => Promise<T>) => task(),
1624
}));
1725

1826
vi.mock("node:timers/promises", () => ({
@@ -116,6 +124,36 @@ describe("installVitePlus", () => {
116124
expect(addPath).toHaveBeenCalledWith("/home/runner/.vite-plus/bin");
117125
});
118126

127+
it("reuses an installed exact version after waiting for the shared install lock", async () => {
128+
const home = mkdtempSync(join(tmpdir(), "setup-vp-home-"));
129+
vi.stubEnv("HOME", home);
130+
const binary = join(home, ".vite-plus", "current", "bin", "vp");
131+
mkdirSync(join(home, ".vite-plus", "current", "bin"), { recursive: true });
132+
writeFileSync(binary, "#!/bin/sh\n");
133+
vi.mocked(getExecOutput)
134+
.mockResolvedValueOnce({ exitCode: 0, stdout: "vp v0.3.0\n", stderr: "" })
135+
.mockResolvedValueOnce({
136+
exitCode: 0,
137+
stdout: [
138+
"data\t/test/data",
139+
"bin\t/test/data/bin",
140+
"cache\t/test/cache",
141+
"config\t/test/config",
142+
"state\t/test/state",
143+
].join("\n"),
144+
stderr: "",
145+
});
146+
147+
try {
148+
await installVitePlus({ ...baseInputs, version: "0.3.0" });
149+
150+
expect(exec).toHaveBeenCalledWith(binary, ["env", "setup", "--refresh"]);
151+
expect(addPath).toHaveBeenCalledWith("/test/data/bin");
152+
} finally {
153+
rmSync(home, { recursive: true, force: true });
154+
}
155+
});
156+
119157
it.each([
120158
{ version: "0.3.0", output: "vp v0.2.9\n" },
121159
{ version: `0.0.0-commit.${commitSha}`, output: "bin\t/test/data/bin\n" },

src/install-viteplus.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { info, warning, addPath } from "@actions/core";
2-
import { exec } from "@actions/exec";
2+
import { exec, getExecOutput } from "@actions/exec";
3+
import { existsSync, writeFileSync } from "node:fs";
34
import { delimiter, join } from "node:path";
45
import { setTimeout as sleep } from "node:timers/promises";
56
import { getInstallScriptUrls, pkgPrNewCommitSha } from "./ci/install-script-urls.js";
@@ -14,6 +15,8 @@ import {
1415
import type { Inputs } from "./types.js";
1516
import { DISPLAY_NAME } from "./types.js";
1617
import { getVitePlusHome } from "./utils.js";
18+
import { withVitePlusInstallLock } from "./install-lock.js";
19+
import { parseInstalledVpVersion } from "./ci/version.js";
1720

1821
// Try each group's URLs in order, for up to N rounds per group (max attempts
1922
// per group = rounds * URLs). Two rounds × two URLs = 4 attempts, ~1 minute
@@ -22,6 +25,10 @@ const INSTALL_MAX_ROUNDS = 2;
2225
const INSTALL_RETRY_DELAY_MS = 2000;
2326

2427
export async function installVitePlus(inputs: Inputs): Promise<void> {
28+
await withVitePlusInstallLock(getVitePlusHome(), () => installVitePlusUnlocked(inputs));
29+
}
30+
31+
async function installVitePlusUnlocked(inputs: Inputs): Promise<void> {
2532
const { version } = inputs;
2633

2734
info(`Installing ${DISPLAY_NAME}@${version}...`);
@@ -51,6 +58,13 @@ export async function installVitePlus(inputs: Inputs): Promise<void> {
5158
env.VP_NODE_MANAGER = inputs.nodeManager ? "yes" : "no";
5259
}
5360

61+
if (await canReuseInstalledVersion(version)) {
62+
info(`Reusing installed ${DISPLAY_NAME}@${version}.`);
63+
await restoreReusedInstall(version, dirsFile, inputs.nodeManager);
64+
ensureVitePlusBinInPath(version, dirsFile, !dirsFile);
65+
return;
66+
}
67+
5468
// For pkg.pr.new preview builds, tell the install script to fetch from
5569
// pkg.pr.new (bypassing the npm registry) instead of resolving VP_VERSION.
5670
const prVersion = pkgPrNewCommitSha(version);
@@ -124,8 +138,60 @@ async function runInstallCommand(url: string, env: { [key: string]: string }): P
124138
return exec(command, args, options);
125139
}
126140

127-
function ensureVitePlusBinInPath(version: string, dirsFile: string | undefined): void {
128-
const binDir = resolveVitePlusBinDir(version, dirsFile, join(getVitePlusHome(), "bin"));
141+
async function canReuseInstalledVersion(version: string): Promise<boolean> {
142+
const requestedVersion = version.replace(/^v/, "");
143+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(requestedVersion)) return false;
144+
145+
const binary = getCurrentVitePlusBinary();
146+
if (!existsSync(binary)) return false;
147+
148+
const result = await getExecOutput(binary, ["--version"], {
149+
ignoreReturnCode: true,
150+
silent: true,
151+
});
152+
return result.exitCode === 0 && parseInstalledVpVersion(result.stdout) === requestedVersion;
153+
}
154+
155+
async function restoreReusedInstall(
156+
version: string,
157+
dirsFile: string | undefined,
158+
nodeManager: boolean | undefined,
159+
): Promise<void> {
160+
const binary = getCurrentVitePlusBinary();
161+
162+
if (dirsFile) {
163+
const result = await getExecOutput(binary, [], {
164+
env: { ...process.env, VP_DUMP_DIRS: "1" },
165+
ignoreReturnCode: true,
166+
silent: true,
167+
});
168+
if (result.exitCode !== 0) {
169+
throw new Error(`Could not read VpDirs from reused ${DISPLAY_NAME}@${version}.`);
170+
}
171+
writeFileSync(dirsFile, result.stdout);
172+
}
173+
174+
// The installer refreshes managed Node.js shims by default on CI. Reapply
175+
// that behavior after reuse; node-manager: false is reconciled by runMain's
176+
// subsequent `vp env off` call.
177+
if (nodeManager !== false) {
178+
await exec(binary, ["env", "setup", "--refresh"]);
179+
}
180+
}
181+
182+
function getCurrentVitePlusBinary(): string {
183+
return join(getVitePlusHome(), "current", "bin", process.platform === "win32" ? "vp.exe" : "vp");
184+
}
185+
186+
function ensureVitePlusBinInPath(
187+
version: string,
188+
dirsFile: string | undefined,
189+
allowLegacyBin = false,
190+
): void {
191+
const legacyBinDir = join(getVitePlusHome(), "bin");
192+
const binDir = allowLegacyBin
193+
? legacyBinDir
194+
: resolveVitePlusBinDir(version, dirsFile, legacyBinDir);
129195
if (!process.env.PATH?.split(delimiter).includes(binDir)) {
130196
addPath(binDir);
131197
}

0 commit comments

Comments
 (0)