diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe1282..84f7093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` --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 diff --git a/CLAUDE.md b/CLAUDE.md index b7f0f66..a8e0c7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` --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 diff --git a/bin/napi-mojo.mjs b/bin/napi-mojo.mjs index 36b2851..a452561 100644 --- a/bin/napi-mojo.mjs +++ b/bin/napi-mojo.mjs @@ -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 @@ -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'); @@ -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; diff --git a/tests/cli.test.js b/tests/cli.test.js index d171eec..3c78a4f 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -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); @@ -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'); @@ -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'));