-
Notifications
You must be signed in to change notification settings - Fork 20
fix: serialize shared Vite+ installs #136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 }); | ||
| } | ||
| }); | ||
| }); |
| 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 }); | ||
| } | ||
| } | ||
| 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"; | ||
|
|
@@ -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 | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When concurrent jobs share Vite+ through Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| async function installVitePlusUnlocked(inputs: Inputs): Promise<void> { | ||
| const { version } = inputs; | ||
|
|
||
| info(`Installing ${DISPLAY_NAME}@${version}...`); | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two jobs sharing a Vite+ home request the same exact version with different Useful? React with 👍 / 👎.
Comment on lines
+61
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For every reusable Vite+ 0.3+ exact-version request, 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); | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When multiple supported overrides are set, this order disagrees with the installer selection in Useful? React with 👍 / 👎. |
||
| if (configuredHome) return configuredHome; | ||
|
|
||
| const home = process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME; | ||
| return join(home || homedir(), ".vite-plus"); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.