Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/build-theme-test-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/cli': patch
---

[chore] Serialize test-suite package builds with a cross-process lock: the two build-theme test files and build-css.test.mjs each build into packages/core/dist from parallel vitest workers, and unsynchronized builds raced each other's rimraf/output, failing CI with ENOTEMPTY or an unresolvable dist/index.js (#3479)
@arham766
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ apps/example-nextjs-tailwind/.next
# Lockfiles (pnpm is the package manager — see pnpm-workspace.yaml)
package-lock.json
yarn.lock
.repo-build-lock/
24 changes: 15 additions & 9 deletions packages/cli/src/commands/build-theme.import-path.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {fileURLToPath} from 'node:url';
import {withRepoBuildLock} from '../../../../scripts/repo-build-lock.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLI_BIN = path.resolve(__dirname, '../../bin/astryx.mjs');
Expand Down Expand Up @@ -57,16 +58,21 @@ function writeTheme(dir, name) {

// `astryx theme build` imports the compiled @astryxdesign/core/theme entry (there is no
// in-CLI fallback generator). Build core once if it isn't already present so
// the suite works in any CI job, regardless of job ordering.
// the suite works in any CI job, regardless of job ordering. The build runs
// under the repo build lock and re-checks inside it: other test files also
// build into packages/core/dist from parallel workers, and unsynchronized
// builds race each other's rimraf/output (#3479).
beforeAll(() => {
if (!fs.existsSync(CORE_THEME_ENTRY)) {
execFileSync('pnpm', ['-F', '@astryxdesign/core', 'build'], {
cwd: REPO_ROOT,
stdio: 'pipe',
timeout: 180_000,
});
}
}, 200_000);
withRepoBuildLock(() => {
if (!fs.existsSync(CORE_THEME_ENTRY)) {
execFileSync('pnpm', ['-F', '@astryxdesign/core', 'build'], {
cwd: REPO_ROOT,
stdio: 'pipe',
timeout: 180_000,
});
}
});
}, 500_000);

let tmpDir;
beforeEach(() => {
Expand Down
24 changes: 15 additions & 9 deletions packages/cli/src/commands/build-theme.prose.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {fileURLToPath} from 'node:url';
import {withRepoBuildLock} from '../../../../scripts/repo-build-lock.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLI_BIN = path.resolve(__dirname, '../../bin/astryx.mjs');
Expand Down Expand Up @@ -69,16 +70,21 @@ function writeTheme(dir, name) {
}

// `astryx theme build` imports the compiled @astryxdesign/core/theme entry. Build core
// once if it isn't already present so the suite works in any CI job.
// once if it isn't already present so the suite works in any CI job. The build runs
// under the repo build lock and re-checks inside it: other test files also build
// into packages/core/dist from parallel workers, and unsynchronized builds race
// each other's rimraf/output (#3479).
beforeAll(() => {
if (!fs.existsSync(CORE_THEME_ENTRY)) {
execFileSync('pnpm', ['-F', '@astryxdesign/core', 'build'], {
cwd: REPO_ROOT,
stdio: 'pipe',
timeout: 180_000,
});
}
}, 200_000);
withRepoBuildLock(() => {
if (!fs.existsSync(CORE_THEME_ENTRY)) {
execFileSync('pnpm', ['-F', '@astryxdesign/core', 'build'], {
cwd: REPO_ROOT,
stdio: 'pipe',
timeout: 180_000,
});
}
});
}, 500_000);

let tmpDir;
beforeEach(() => {
Expand Down
9 changes: 7 additions & 2 deletions scripts/build-css.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import fs from 'fs/promises';
import path from 'path';
import {fileURLToPath} from 'url';
import {execSync} from 'child_process';
import {withRepoBuildLock} from './repo-build-lock.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
Expand Down Expand Up @@ -50,9 +51,13 @@ describe('build-css astryx.css', () => {

beforeAll(async () => {
console.log('Running pnpm build...');
execSync('pnpm build', {cwd: ROOT, stdio: 'pipe', timeout: 120_000});
// Serialized with the build-theme suites, which build @astryxdesign/core
// into the same dist directory from parallel workers (#3479).
withRepoBuildLock(() => {
execSync('pnpm build', {cwd: ROOT, stdio: 'pipe', timeout: 300_000});
});
astryxCss = await fs.readFile(path.join(CORE_DIST, 'astryx.css'), 'utf8');
}, 180_000);
}, 500_000);

it('contains @media rules', () => {
const mediaRules = extractMediaRules(astryxCss);
Expand Down
62 changes: 62 additions & 0 deletions scripts/repo-build-lock.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file Cross-process lock for tests that build packages into shared dist dirs.
*
* Three test files run a repo package build from beforeAll (scripts/
* build-css.test.mjs, packages/cli/src/commands/build-theme.prose.test.mjs and
* build-theme.import-path.test.mjs). Vitest runs test files in parallel worker
* processes, so without coordination two of them can build @astryxdesign/core
* into the same dist directory at once: one build's `rimraf dist` races the
* other's output, failing with ENOTEMPTY or an unresolvable dist/index.js.
*
* withRepoBuildLock serializes those builds with an atomic lock directory
* (fs.mkdirSync either creates it or throws), waiting for the holder to
* finish before proceeding.
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import {fileURLToPath} from 'node:url';

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCK_DIR = path.join(REPO_ROOT, '.repo-build-lock');

function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

/**
* Run `fn` while holding the repo-wide build lock. Blocks (synchronously)
* until the lock is free. If the lock is not released within `timeoutMs`,
* throws with a hint about removing a stale lock left by a crashed process.
*
* @template T
* @param {() => T} fn
* @param {{timeoutMs?: number}} [options]
* @returns {T}
*/
export function withRepoBuildLock(fn, {timeoutMs = 300_000} = {}) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
fs.mkdirSync(LOCK_DIR);
break;
} catch (e) {
if (e.code !== 'EEXIST') throw e;
if (Date.now() > deadline) {
throw new Error(
`Timed out waiting for the repo build lock (${LOCK_DIR}). ` +
'If no build is running, a crashed process may have left a stale ' +
'lock; remove the directory and retry.',
);
}
sleepSync(250);
}
}
try {
return fn();
} finally {
fs.rmdirSync(LOCK_DIR);
}
}
Loading