Skip to content
Merged
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
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Mops CLI Changelog

## Next
- Fix `mops install` race conditions when multiple processes install into the same project (e.g. an editor watcher, fixture installers like vscode-motoko's, or CI matrix jobs sharing a global cache). Concurrent runs could observe a half-populated global cache or local `.mops/<pkg>` directory and copy zero-byte / truncated files, surfacing later as missing completions, hover data, or type-check errors. Cache writes (mops registry, GitHub installs, and project-local `.mops/`) now stage into a sibling `.staging-*` dir and atomically rename onto the canonical path. Stale staging dirs from interrupted runs are swept on the next install. The shared `.mops/_tmp/` zip download dir used by GitHub installs is also per-invocation now. If you have zero-byte files left over in your cache from a pre-fix crash, run `mops cache clean` once after upgrading.

## 2.13.2
- Fix race conditions when two `mops` processes run on the same project (e.g. an editor watcher and `caffeine check --fix`, or back-to-back invocations). `mops check-stable` used a shared `.mops/.check-stable/` scratch dir and `mops check`/`build`/`check-stable` used a shared `<parent>/.migrations-<canister>/` staging dir; concurrent runs would clobber each other and surface as misleading errors like `.mops/.check-stable/new.most: No such file or directory` or `EEXIST: file already exists, symlink ...`. Both directories are now per-invocation (created via `mkdtemp` and removed when the command finishes).
Expand Down
121 changes: 101 additions & 20 deletions cli/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,75 @@ let getGlobalCacheDir = () => {
return path.join(globalCacheDir, network === "ic" ? "" : network);
};

// Cache writes stage into a sibling `<dest>/../.staging-<rand>` and atomic-
// rename onto `dest` so concurrent processes never observe a partial cache.
const STAGING_PREFIX = ".staging-";

export function createStagingDir(dest: string): string {
let parent = path.dirname(dest);
fs.mkdirSync(parent, { recursive: true });
return fs.mkdtempSync(path.join(parent, STAGING_PREFIX));
}

// Returns false if another process committed first (race lost) — `staging`
// is removed and the existing `dest` is left intact. We only swallow
// `EPERM` (Windows) when `dest` exists; otherwise it's likely AV / open
// handles and bubbles up.
export function commitStagingDir(staging: string, dest: string): boolean {
try {
fs.renameSync(staging, dest);
return true;
} catch (err: any) {
let raceLost =
(err.code === "ENOTEMPTY" ||
err.code === "EEXIST" ||
err.code === "EPERM") &&
fs.existsSync(dest);
if (raceLost) {
fs.rmSync(staging, { recursive: true, force: true });
return false;
}
throw err;
}
}

// Sweep leftover `.staging-*` from interrupted runs. Mtime cutoff avoids
// clobbering siblings that are mid-staging right now. Runs at most once
// per process.
const STAGING_STALE_MS = 60 * 60 * 1000;
let swept = false;
export function sweepStaleStagingDirs() {
if (swept) {
return;
}
swept = true;
let cutoff = Date.now() - STAGING_STALE_MS;
let parents = [
path.join(getGlobalCacheDir(), "packages"),
path.join(getGlobalCacheDir(), "packages", "_github"),
path.join(getRootDir(), ".mops"),
path.join(getRootDir(), ".mops", "_github"),
];
for (let parent of parents) {
if (!fs.existsSync(parent)) {
continue;
}
for (let entry of fs.readdirSync(parent)) {
if (!entry.startsWith(STAGING_PREFIX)) {
continue;
}
let full = path.join(parent, entry);
try {
if (fs.statSync(full).mtimeMs < cutoff) {
fs.rmSync(full, { recursive: true, force: true });
}
} catch {
// raced with another sweeper; ignore
}
}
}
}

export let show = () => {
return getGlobalCacheDir();
};
Expand Down Expand Up @@ -49,32 +118,44 @@ export function getGithubDepCacheName(name: string, repo: string) {
);
}

export let addCache = (cacheName: string, source: string) => {
export let addCache = async (cacheName: string, source: string) => {
let dest = path.join(getGlobalCacheDir(), "packages", cacheName);
fs.mkdirSync(dest, { recursive: true });

return new Promise<void>((resolve, reject) => {
ncp.ncp(source, dest, { stopOnErr: true }, (err) => {
if (err) {
reject(err);
}
resolve();
let staging = createStagingDir(dest);

try {
await new Promise<void>((resolve, reject) => {
ncp.ncp(source, staging, { stopOnErr: true }, (err) => {
if (err) {
reject(err);
}
resolve();
});
});
});
commitStagingDir(staging, dest);
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
throw err;
}
};

export let copyCache = (cacheName: string, dest: string) => {
export let copyCache = async (cacheName: string, dest: string) => {
let source = path.join(getGlobalCacheDir(), "packages", cacheName);
fs.mkdirSync(dest, { recursive: true });

return new Promise<void>((resolve, reject) => {
ncp.ncp(source, dest, { stopOnErr: true }, (err) => {
if (err) {
reject(err);
}
resolve();
let staging = createStagingDir(dest);

try {
await new Promise<void>((resolve, reject) => {
ncp.ncp(source, staging, { stopOnErr: true }, (err) => {
if (err) {
reject(err);
}
resolve();
});
});
});
commitStagingDir(staging, dest);
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
throw err;
}
};

export let cacheSize = async () => {
Expand Down
23 changes: 14 additions & 9 deletions cli/commands/install/install-mops-dep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import path from "node:path";
import { Buffer } from "node:buffer";
import { createLogUpdate } from "log-update";
import chalk from "chalk";
import { deleteSync } from "del";
import { checkConfigFile, progressBar, readConfig } from "../../mops.js";
import { getHighestVersion } from "../../api/getHighestVersion.js";
import { storageActor } from "../../api/actors.js";
import { parallel } from "../../parallel.js";
import {
commitStagingDir,
createStagingDir,
getDepCacheDir,
getMopsDepCacheName,
isDepCached,
sweepStaleStagingDirs,
} from "../../cache.js";
import {
downloadFile,
Expand Down Expand Up @@ -69,6 +71,8 @@ export async function installMopsDep(
version = versionRes.ok;
}

sweepStaleStagingDirs();

let cacheName = getMopsDepCacheName(depName, version);
let cacheDir = getDepCacheDir(cacheName);

Expand Down Expand Up @@ -100,33 +104,34 @@ export async function installMopsDep(
progress();
});

let stagingDir = createStagingDir(cacheDir);
let onSigInt = () => {
deleteSync([cacheDir], { force: true });
process.exit();
fs.rmSync(stagingDir, { recursive: true, force: true });
process.exit(130);
};
process.on("SIGINT", onSigInt);

// write files to global cache
try {
await Promise.all(
Array.from(filesData.entries()).map(async ([filePath, data]) => {
await fs.promises.mkdir(
path.join(cacheDir, path.dirname(filePath)),
path.join(stagingDir, path.dirname(filePath)),
{ recursive: true },
);
await fs.promises.writeFile(
path.join(cacheDir, filePath),
path.join(stagingDir, filePath),
Buffer.from(data),
);
}),
);
commitStagingDir(stagingDir, cacheDir);
} catch (err) {
console.error(chalk.red("Error: ") + err);
deleteSync([cacheDir], { force: true });
fs.rmSync(stagingDir, { recursive: true, force: true });
return false;
} finally {
process.off("SIGINT", onSigInt);
}

process.off("SIGINT", onSigInt);
} catch (err) {
console.error(chalk.red("Error: ") + err);
return false;
Expand Down
8 changes: 7 additions & 1 deletion cli/commands/install/sync-local-cache.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import fs from "node:fs";
import path from "node:path";
import { copyCache, getDepCacheName } from "../../cache.js";
import {
copyCache,
getDepCacheName,
sweepStaleStagingDirs,
} from "../../cache.js";
import { getDependencyType, getRootDir } from "../../mops.js";
import { resolvePackages } from "../../resolve-packages.js";

export async function syncLocalCache({ verbose = false } = {}): Promise<
Record<string, string>
> {
sweepStaleStagingDirs();

let resolvedPackages = await resolvePackages();
let rootDir = getRootDir();

Expand Down
73 changes: 72 additions & 1 deletion cli/tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { describe, expect, jest, test } from "@jest/globals";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "path";
import { cli, normalizePaths } from "./helpers";

Expand Down Expand Up @@ -128,6 +137,68 @@ describe("install", () => {
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
}
});

// Regression: parallel `mops install` runs against the same project used to
// race in two places — global cache writes (`.mops/<pkg>` populated mid-write)
// and local `.mops/<pkg>` copies — leaving zero-byte / partially-written
// files. We isolate the global cache via `XDG_CACHE_HOME` so the global-write
// path actually executes (cold-cache scenario).
test("parallel `mops install` produces a complete .mops tree (no zero-byte / staging dirs)", async () => {
const cwd = path.join(import.meta.dirname, "install/success");
const lockFile = path.join(cwd, "mops.lock");
const localCache = path.join(cwd, ".mops");
const xdgCache = mkdtempSync(path.join(tmpdir(), "mops-test-xdg-"));
rmSync(lockFile, { force: true });
rmSync(localCache, { recursive: true, force: true });
try {
const N = 5;
const env = { CI: undefined, XDG_CACHE_HOME: xdgCache };
const runs = await Promise.all(
Array.from({ length: N }, () => cli(["install"], { cwd, env })),
);
for (const r of runs) {
if (r.exitCode !== 0) {
throw new Error(
`mops install exited ${r.exitCode}\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`,
);
}
}

const walk = (dir: string): string[] => {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walk(p));
} else if (entry.isFile()) {
out.push(p);
}
}
return out;
};
const files = walk(localCache);
const empties = files.filter((f) => statSync(f).size === 0);
expect(empties).toEqual([]);

const stagingLeftovers = readdirSync(localCache).filter((e) =>
e.startsWith(".staging-"),
);
expect(stagingLeftovers).toEqual([]);

const globalPkg = path.join(
xdgCache,
"mops",
"packages",
"core@1.0.0",
"mops.toml",
);
expect(existsSync(globalPkg)).toBe(true);
} finally {
rmSync(lockFile, { force: true });
rmSync(localCache, { recursive: true, force: true });
rmSync(xdgCache, { recursive: true, force: true });
}
});
});

// `mops update` and `mops outdated` default to caret-bound resolution: stay
Expand Down
Loading
Loading