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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ break the source API that downstream addons compile against.
now follows symlinks, with a cycle guard, and hashes `.🔥`, `.mojopkg` and
`.mojoc` too; `node_modules` is no longer walked. `tests/cli.test.js` covers
each rule with a stub compiler, and each was checked by reverting it.
- **`napi-mojo run` reused its cached binary across a toolchain upgrade.** The
cache key held the compiler command, which a `pixi.lock` or MAX bump leaves
unchanged, so the next run skipped the new compiler and loaded the old binary
against the new runtime (the binary finds it through its RUNPATH, which pixi
upgrades in place). The key now includes what `<command> --version` reports,
build hash and all; that costs one probe per run, ~55 ms through `pixi run`
on a ~170 ms cached run. A compiler that runs but reports no version turns
the cache off, with a warning.

## 0.16.0 — 2026-09-17

Expand Down
14 changes: 10 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,10 +571,16 @@ through `-I`), so any new way to hand the compiler a directory must extend the
key in the same change, or this becomes a stale-binary trap. The walk used to
skip symlinked directories, and a library symlinked beside the entry, the
obvious workaround before `-I`, then served the old binary after every edit
behind the link; `tests/cli.test.js` pins that with a stub compiler. One input
is still NOT in the key: the compiler's version, so a toolchain upgrade under
the same `pixi run mojo` keeps the old binary until `--rebuild`. `--rebuild`
forces a recompile; CI asserts a second run is byte-identical.
behind the link; `tests/cli.test.js` pins that with a stub compiler. **The
compiler itself is in the key too**: whatever `<command> --version` prints,
e.g. `Mojo 1.1.0 (8189361e)`, build hash included, so one nightly to the next
counts. The command string alone survives a toolchain upgrade, and the old
binary then skipped the new compiler AND loaded the new runtime: it finds
`libKGENCompilerRTShared` through its RUNPATH, the environment's `lib/`, which
pixi upgrades in place. The probe costs ~55 ms through `pixi run` (~17 ms
direct) on a ~170 ms cached run. A compiler that runs but reports no version
turns the cache off, with a warning, rather than keying on nothing.
`--rebuild` forces a recompile; CI asserts a second run is byte-identical.

**`-I` roots go after the framework's.** Mojo takes the first root holding a
package of the imported name and says nothing about the rest, so a library
Expand Down
45 changes: 36 additions & 9 deletions bin/napi-mojo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -559,12 +559,12 @@ if (typeof rc === 'number' && Number.isInteger(rc)) process.exitCode = rc;
}

// Every input `run` supports, hashed: the user's tree, the framework tree,
// each library root in order, the compiler command and the generated
// wrapper. The directories a compile can read are exactly the entry's (Mojo
// resolves a plain import beside the main module), the framework root and
// the -I roots, and all of them are walked — which is what makes skipping the
// compile safe rather than a stale-binary trap. `--rebuild` is the escape
// hatch.
// each library root in order, the compiler command, what that compiler says
// it is, and the generated wrapper. The directories a compile can read are
// exactly the entry's (Mojo resolves a plain import beside the main module),
// the framework root and the -I roots, and all of them are walked — which is
// what makes skipping the compile safe rather than a stale-binary trap.
// `--rebuild` is the escape hatch.
//
// The walk hashes every file that can change the compile: source (.mojo,
// .🔥) and precompiled packages (.mojopkg, .mojoc). It follows symlinks, with
Expand Down Expand Up @@ -601,9 +601,34 @@ function mojoFilesUnder(dir) {
return out;
}

function buildKey({ entryDir, include, libs, mojoCmd, wrapperSrc }) {
// The compiler's identity, e.g. "Mojo 1.1.0 (8189361e)": the command string
// survives a toolchain upgrade, and a key without this kept the old binary,
// which then skipped the new compiler AND loaded the new runtime (the binary
// finds libKGENCompilerRTShared through its RUNPATH, the environment's lib/,
// which pixi upgrades in place). The build hash is in the output, so one
// nightly to the next counts as a change. Measured on a ~170 ms cached run:
// ~55 ms through `pixi run`, ~17 ms direct. Null when the compiler runs but
// cannot say, and then nothing is served from the cache; null and silent when
// it cannot run at all, because the compile will fail with the full advice.
function compilerIdentity(mojo) {
const res = spawnSync(mojo[0], [...mojo.slice(1), '--version'], { encoding: 'utf8' });
if (res.error) return null;
const id = res.status === 0 ? (res.stdout || '').trim() : '';
if (!id) {
console.error(
`napi-mojo: warning: \`${mojo.join(' ')} --version\` reported no compiler version, ` +
'so the run cache is off for this run'
);
return null;
}
return id;
}

function buildKey({ entryDir, include, libs, mojoCmd, compilerId, wrapperSrc }) {
const h = createHash('sha256');
const parts = [VERSION, mojoCmd.join(' '), include, String(libs.length), ...libs, wrapperSrc];
const parts = [
VERSION, mojoCmd.join(' '), compilerId, include, String(libs.length), ...libs, wrapperSrc,
];
for (const part of parts) {
h.update(part);
h.update('\0');
Expand Down Expand Up @@ -664,9 +689,11 @@ function cmdRun(argv) {
const mojo = resolveMojoCmd(opts.mojo);
const wrapperSrc = hostEntrySource(aliasModule);
const stamp = join(workDir, 'build.key');
const key = buildKey({ entryDir, include, libs, mojoCmd: mojo, wrapperSrc });
const compilerId = compilerIdentity(mojo);
const key = buildKey({ entryDir, include, libs, mojoCmd: mojo, compilerId: compilerId ?? '', wrapperSrc });
const cached =
!opts.rebuild &&
compilerId !== null &&
existsSync(addon) &&
existsSync(stamp) &&
readFileSync(stamp, 'utf8').trim() === key;
Expand Down
41 changes: 35 additions & 6 deletions tests/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,15 +429,18 @@ describe('the scaffolded loader explains load failures', () => {
});

// `-I` adds library include roots after the framework's, and `run`'s build
// cache must see every file that can change the compile. The compiler here is
// a stub passed through --mojo: it records each invocation and writes a dummy
// output, so what is tested is exactly what the CLI decides — which roots it
// passes, in what order, and when it recompiles — with no toolchain. The
// real compile is the "Host-mode program with library include roots" CI step.
describe('library include roots (-I)', () => {
// cache must see every input that can change the compile: the files, and the
// compiler itself. The compiler here is a stub passed through --mojo: it
// reports a version from a file the tests can change, records each compile
// and writes a dummy output, so what is tested is exactly what the CLI
// decides — which roots it passes, in what order, and when it recompiles —
// with no toolchain. The real compile is the "Host-mode program with library
// include roots" CI step.
describe('run build cache and library include roots (-I)', () => {
const { writeFileSync, mkdirSync, symlinkSync } = require('fs');
let log;
let stub;
let versionFile;

const write = (rel, text) => {
const p = path.join(dir, rel);
Expand All @@ -453,8 +456,15 @@ describe('library include roots (-I)', () => {

beforeEach(() => {
log = path.join(dir, 'compiles.jsonl');
versionFile = write('stub-version.txt', 'Mojo 1.1.0 (stub)\n');
stub = write('stub-mojo.cjs', `const fs = require('fs');
const args = process.argv.slice(2);
if (args[0] === '--version') {
// What \`run\` asks for the compiler's identity. Not a compile, so not logged.
if (!fs.existsSync(${JSON.stringify(versionFile)})) process.exit(1);
process.stdout.write(fs.readFileSync(${JSON.stringify(versionFile)}, 'utf8'));
process.exit(0);
}
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + '\\n');
const o = args.indexOf('-o');
if (o !== -1) fs.writeFileSync(args[o + 1], 'not a real addon');
Expand Down Expand Up @@ -529,6 +539,25 @@ if (o !== -1) fs.writeFileSync(args[o + 1], 'not a real addon');
expect(compiles()).toHaveLength(2);
});

// The command string survives a toolchain upgrade, so the key holds what
// the compiler says it is. Without it the old binary skips the new compiler
// and loads the new runtime: its RUNPATH points into the environment, which
// pixi upgrades in place.
test('a compiler that reports another version recompiles', () => {
cli('run');
write('stub-version.txt', 'Mojo 1.2.0 (stub)\n');
cli('run');
expect(compiles()).toHaveLength(2);
});

test('a compiler that cannot report its version is never served from the cache', () => {
rmSync(versionFile);
const first = cli('run');
cli('run');
expect(compiles()).toHaveLength(2);
expect(first.stderr).toContain('run cache is off');
});

test('reordering library roots recompiles', () => {
cli('run', I('lib1', 'lib2'));
cli('run', I('lib2', 'lib1'));
Expand Down
Loading