Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 65 additions & 65 deletions dist/index.mjs

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions src/install-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vite-plus/test";
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { withVitePlusInstallLock } from "./install-lock.js";

describe("withVitePlusInstallLock", () => {
it("serializes concurrent installs that share a Vite+ home", async () => {
const root = await mkdtemp(join(tmpdir(), "setup-vp-lock-"));
let active = 0;
let maxActive = 0;

try {
await Promise.all(
Array.from({ length: 3 }, () =>
withVitePlusInstallLock(join(root, ".vite-plus"), async () => {
active++;
maxActive = Math.max(maxActive, active);
await sleep(10);
active--;
}),
),
);

expect(maxActive).toBe(1);
} finally {
await rm(root, { recursive: true, force: true });
}
});

it("recovers a stale lock that was interrupted before owner metadata existed", async () => {
const root = await mkdtemp(join(tmpdir(), "setup-vp-lock-"));
const vitePlusHome = join(root, ".vite-plus");
const lockPath = `${vitePlusHome}.setup-vp-lock`;
const old = new Date(Date.now() - 31 * 60 * 1000);

try {
await mkdir(lockPath, { recursive: true });
await utimes(lockPath, old, old);

await expect(withVitePlusInstallLock(vitePlusHome, async () => "recovered")).resolves.toBe(
"recovered",
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
68 changes: 68 additions & 0 deletions src/install-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";

const RETRY_DELAY_MS = 250;
const STALE_LOCK_MS = 30 * 60 * 1000;

interface LockMetadata {
createdAt: number;
pid: number;
}

/**
* Serialize changes to Vite+'s process-wide installation. Self-hosted runners
* can execute multiple jobs under one HOME, while Vite+ updates its `current`
* shim and version directories in place.
*/
export async function withVitePlusInstallLock<T>(
vitePlusHome: string,
task: () => Promise<T>,
): Promise<T> {
const lockPath = `${vitePlusHome}.setup-vp-lock`;

await acquireLock(lockPath);
try {
return await task();
} finally {
await rm(lockPath, { recursive: true, force: true });
}
}

async function acquireLock(lockPath: string): Promise<void> {
await mkdir(dirname(lockPath), { recursive: true });

for (;;) {
try {
await mkdir(lockPath);
await writeFile(
join(lockPath, "owner.json"),
JSON.stringify({ createdAt: Date.now(), pid: process.pid } satisfies LockMetadata),
);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
await removeStaleLock(lockPath);
await sleep(RETRY_DELAY_MS);
}
}
}

async function removeStaleLock(lockPath: string): Promise<void> {
try {
const contents = await readFile(join(lockPath, "owner.json"), "utf8");
const metadata = JSON.parse(contents) as Partial<LockMetadata>;
await removeWhenExpired(lockPath, metadata.createdAt);
} catch {
// A process can be interrupted between mkdir and writing owner.json. The
// directory timestamp gives that partial lock the same recovery path.
await removeWhenExpired(lockPath);
}
}

async function removeWhenExpired(lockPath: string, createdAt?: number): Promise<void> {
const lockAgeStart = createdAt ?? (await stat(lockPath)).mtimeMs;
if (Date.now() - lockAgeStart > STALE_LOCK_MS) {
await rm(lockPath, { recursive: true, force: true });
Comment on lines +64 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep active installs from aging out of the lock

If an installation task remains active for more than 30 minutes, a waiter removes its lock solely because the fixed creation timestamp has expired and starts mutating the same Vite+ home concurrently; when the original task finishes, its unconditional cleanup can also delete the successor's lock. The wrapper only bounds fetching the installer script, not everything the downloaded installer can do, so use owner liveness or a refreshed heartbeat and verify ownership before deleting the lock.

Useful? React with 👍 / 👎.

}
}
92 changes: 90 additions & 2 deletions src/install-viteplus.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
import { exec } from "@actions/exec";
import { exec, getExecOutput } from "@actions/exec";
import { addPath, warning } from "@actions/core";
import { writeFileSync } from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installVitePlus } from "./install-viteplus.js";
import type { Inputs } from "./types.js";

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

vi.mock("@actions/exec", () => ({
exec: vi.fn(),
getExecOutput: vi.fn(),
}));

vi.mock("./install-lock.js", () => ({
withVitePlusInstallLock: async <T>(_home: string, task: () => Promise<T>) => task(),
}));

vi.mock("node:timers/promises", () => ({
Expand Down Expand Up @@ -65,6 +73,9 @@ describe("installVitePlus", () => {
vi.stubEnv("VP_NODE_MANAGER", undefined);
vi.stubEnv("VP_VPDIRS_AWARE", undefined);
vi.stubEnv("SETUP_VP_DIRS_FILE", undefined);
vi.stubEnv("VP_HOME", undefined);
vi.stubEnv("INSTALL_DIR", undefined);
vi.stubEnv("SHIM_DIR", undefined);
});

afterEach(() => {
Expand Down Expand Up @@ -116,6 +127,83 @@ describe("installVitePlus", () => {
expect(addPath).toHaveBeenCalledWith("/home/runner/.vite-plus/bin");
});

it("reuses an installed exact version after waiting for the shared install lock", async () => {
const home = mkdtempSync(join(tmpdir(), "setup-vp-home-"));
vi.stubEnv("HOME", home);
const binary = join(home, ".vite-plus", "current", "bin", "vp");
mkdirSync(join(home, ".vite-plus", "current", "bin"), { recursive: true });
writeFileSync(binary, "#!/bin/sh\n");
vi.mocked(getExecOutput)
.mockResolvedValueOnce({ exitCode: 0, stdout: "vp v0.3.0\n", stderr: "" })
.mockResolvedValueOnce({
exitCode: 0,
stdout: [
"data\t/test/data",
"bin\t/test/data/bin",
"cache\t/test/cache",
"config\t/test/config",
"state\t/test/state",
].join("\n"),
stderr: "",
});

try {
await installVitePlus({ ...baseInputs, version: "0.3.0" });

expect(exec).toHaveBeenCalledWith(binary, ["env", "setup", "--refresh"]);
expect(addPath).toHaveBeenCalledWith("/test/data/bin");
} finally {
rmSync(home, { recursive: true, force: true });
}
});

it("reinstalls when an existing Vite+ binary cannot be probed", async () => {
const home = mkdtempSync(join(tmpdir(), "setup-vp-home-"));
vi.stubEnv("VP_HOME", join(home, ".shared-vite-plus"));
const binary = join(home, ".shared-vite-plus", "current", "bin", "vp");
mkdirSync(join(home, ".shared-vite-plus", "current", "bin"), { recursive: true });
writeFileSync(binary, "#!/bin/sh\n");
vi.mocked(getExecOutput).mockRejectedValueOnce(new Error("spawn EACCES"));
mockSuccessfulInstallOnce();

try {
await installVitePlus({ ...baseInputs, version: "0.3.0" });

expect(exec).toHaveBeenCalledTimes(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

it("reconciles disabled Node.js management when reusing an install", async () => {
const home = mkdtempSync(join(tmpdir(), "setup-vp-home-"));
vi.stubEnv("VP_HOME", join(home, ".shared-vite-plus"));
const binary = join(home, ".shared-vite-plus", "current", "bin", "vp");
mkdirSync(join(home, ".shared-vite-plus", "current", "bin"), { recursive: true });
writeFileSync(binary, "#!/bin/sh\n");
vi.mocked(getExecOutput)
.mockResolvedValueOnce({ exitCode: 0, stdout: "vp v0.3.0\n", stderr: "" })
.mockResolvedValueOnce({
exitCode: 0,
stdout: [
"data\t/test/data",
"bin\t/test/data/bin",
"cache\t/test/cache",
"config\t/test/config",
"state\t/test/state",
].join("\n"),
stderr: "",
});

try {
await installVitePlus({ ...baseInputs, version: "0.3.0", nodeManager: false });

expect(exec).toHaveBeenCalledWith(binary, ["env", "off"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

it.each([
{ version: "0.3.0", output: "vp v0.2.9\n" },
{ version: `0.0.0-commit.${commitSha}`, output: "bin\t/test/data/bin\n" },
Expand Down
82 changes: 79 additions & 3 deletions src/install-viteplus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { info, warning, addPath } from "@actions/core";
import { exec } from "@actions/exec";
import { exec, getExecOutput } from "@actions/exec";
import { existsSync, writeFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { getInstallScriptUrls, pkgPrNewCommitSha } from "./ci/install-script-urls.js";
Expand All @@ -14,6 +15,8 @@ import {
import type { Inputs } from "./types.js";
import { DISPLAY_NAME } from "./types.js";
import { getVitePlusHome } from "./utils.js";
import { withVitePlusInstallLock } from "./install-lock.js";
import { parseInstalledVpVersion } from "./ci/version.js";

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

export async function installVitePlus(inputs: Inputs): Promise<void> {
await withVitePlusInstallLock(getVitePlusHome(), () => installVitePlusUnlocked(inputs));
Comment on lines 27 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Lock the installer’s resolved Vite+ home

When concurrent jobs share Vite+ through VP_HOME, INSTALL_DIR, or SHIM_DIR but have different HOME values, this lock is created beside each job’s default $HOME/.vite-plus, so the jobs acquire different locks while the installer writes to the same location. The installer probe explicitly honors those variables in src/ci/vp-dirs.ts, so this leaves the shared-install corruption race unresolved for custom installations; derive the lock location from the same environment variables as the installer.

Useful? React with 👍 / 👎.

}

async function installVitePlusUnlocked(inputs: Inputs): Promise<void> {
const { version } = inputs;

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

if (await canReuseInstalledVersion(version)) {
info(`Reusing installed ${DISPLAY_NAME}@${version}.`);
try {
await restoreReusedInstall(version, dirsFile, inputs.nodeManager);
ensureVitePlusBinInPath(version, dirsFile, !dirsFile);
} finally {
if (dirsFile) removeVitePlusDirsFile(dirsFile);
}
return;
Comment on lines +61 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reapply node-manager configuration before reusing

When two jobs sharing a Vite+ home request the same exact version with different node-manager settings, this early return skips the installer after setting VP_NODE_MANAGER only in the unused child environment. For example, a node-manager: false job following a normal install retains the existing node/npm/npx shims, while a normal job following a disabled install does not recreate them, so the requested manager mode depends on which job installed first. Reuse must also reconcile the requested shim configuration rather than treating the version alone as the complete installation state.

Useful? React with 👍 / 👎.

Comment on lines +61 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the VpDirs probe file after successful reuse

For every reusable Vite+ 0.3+ exact-version request, restoreReusedInstall writes the generated VpDirs path and this early return bypasses the only removeVitePlusDirsFile call in the later installer finally block. Persistent self-hosted runners therefore accumulate one uniquely named file in the system temp directory per action run; wrap the reuse path in equivalent cleanup.

Useful? React with 👍 / 👎.

}

// For pkg.pr.new preview builds, tell the install script to fetch from
// pkg.pr.new (bypassing the npm registry) instead of resolving VP_VERSION.
const prVersion = pkgPrNewCommitSha(version);
Expand Down Expand Up @@ -124,8 +142,66 @@ async function runInstallCommand(url: string, env: { [key: string]: string }): P
return exec(command, args, options);
}

function ensureVitePlusBinInPath(version: string, dirsFile: string | undefined): void {
const binDir = resolveVitePlusBinDir(version, dirsFile, join(getVitePlusHome(), "bin"));
async function canReuseInstalledVersion(version: string): Promise<boolean> {
const requestedVersion = version.replace(/^v/, "");
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(requestedVersion)) return false;

const binary = getCurrentVitePlusBinary();
if (!existsSync(binary)) return false;

try {
const result = await getExecOutput(binary, ["--version"], {
ignoreReturnCode: true,
silent: true,
});
return result.exitCode === 0 && parseInstalledVpVersion(result.stdout) === requestedVersion;
} catch {
return false;
}
}

async function restoreReusedInstall(
version: string,
dirsFile: string | undefined,
nodeManager: boolean | undefined,
): Promise<void> {
const binary = getCurrentVitePlusBinary();

if (dirsFile) {
const result = await getExecOutput(binary, [], {
env: { ...process.env, VP_DUMP_DIRS: "1" },
ignoreReturnCode: true,
silent: true,
});
if (result.exitCode !== 0) {
throw new Error(`Could not read VpDirs from reused ${DISPLAY_NAME}@${version}.`);
}
writeFileSync(dirsFile, result.stdout);
}

// The installer refreshes managed Node.js shims by default on CI. Reapply
// that behavior after reuse so the requested configuration never depends on
// the job that populated the shared home first.
if (nodeManager === false) {
await exec(binary, ["env", "off"]);
} else {
await exec(binary, ["env", "setup", "--refresh"]);
}
}

function getCurrentVitePlusBinary(): string {
return join(getVitePlusHome(), "current", "bin", process.platform === "win32" ? "vp.exe" : "vp");
}

function ensureVitePlusBinInPath(
version: string,
dirsFile: string | undefined,
allowLegacyBin = false,
): void {
const legacyBinDir = join(getVitePlusHome(), "bin");
const binDir = allowLegacyBin
? legacyBinDir
: resolveVitePlusBinDir(version, dirsFile, legacyBinDir);
if (!process.env.PATH?.split(delimiter).includes(binDir)) {
addPath(binDir);
}
Expand Down
13 changes: 13 additions & 0 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getConfiguredProjectDir,
getCacheDirectories,
getInstallCwd,
getVitePlusHome,
isWithin,
parseInstalledVpVersion,
resolvePath,
Expand Down Expand Up @@ -505,6 +506,18 @@ describe("parseInstalledVpVersion", () => {
});
});

describe("getVitePlusHome", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("uses VP_HOME when the installer is configured with one", () => {
vi.stubEnv("VP_HOME", "/shared/vite-plus");

expect(getVitePlusHome()).toBe("/shared/vite-plus");
});
});

describe("isWithin", () => {
it("treats a directory as within itself", () => {
expect(isWithin("/a/b", "/a/b")).toBe(true);
Expand Down
3 changes: 3 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { LockFileType } from "./types.js";
import type { LockFileInfo } from "./types.js";

export function getVitePlusHome(): string {
const configuredHome = process.env.VP_HOME || process.env.INSTALL_DIR || process.env.SHIM_DIR;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match the installer's override precedence

When multiple supported overrides are set, this order disagrees with the installer selection in src/ci/vp-dirs.ts, which uses SHIM_DIR before INSTALL_DIR before VP_HOME. For example, jobs sharing one SHIM_DIR but having different VP_HOME values acquire different locks and probe unrelated $VP_HOME/current installations while the installer writes the same shim directory, so concurrent writes remain possible and reuse can select the wrong installation. Fresh evidence is that the current fix recognizes all three variables but applies the opposite precedence; derive the lock/probe target using the installer's exact resolution rules.

Useful? React with 👍 / 👎.

if (configuredHome) return configuredHome;

const home = process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME;
return join(home || homedir(), ".vite-plus");
}
Expand Down