Files
electron/script/node-spec-runner.js
Samuel Attard d6b4cf6d8f perf: speed up fs operations on paths inside asar archives (#53852)
* perf: make fs operations on paths inside an asar archive cheaper than real files

Loading modules from app.asar was measurably slower than loading the same
files from disk because every stat/realpath/readdir on an archive path paid
for a native path split that built a V8 dictionary, a fake Stats array built
through the slow iterable path, and a real realpath() of the archive file.

- splitPath: find the archive prefix in JS and memoise the native verdict per
  prefix, with a last-prefix fast path; skip path.normalize() for already
  normalized absolute POSIX paths.
- internalModuleStat: consult the module stat cache before splitting the path.
- Fill a shared scratch Float64Array/BigInt64Array for Stats instead of
  allocating one from a JS array per call.
- Memoise the archive file's own realpath per archive; honour the `encoding`
  option for asar realpath results (previously threw for 'buffer').
- readdir(withFileTypes)/opendir: one native readdirWithTypes() call instead
  of a stat per child.
- Native: allocation-free header walk over string_view, a per-archive
  path->node memo, zero-copy ASCII path arguments, and DictionaryTemplate
  results for stat()/getFileInfo().
- Wrap child_process eagerly instead of through a Module._load hook (init.ts
  already requires it; the hook taxed every require() and missed ESM
  imports of node:child_process).
- Read ELECTRON_LOG_ASAR_READS once.

* perf: locate the archive component of a path natively in one call

Replace the JS-side prefix memo and normalization regexes in the asar fs
wrapper with a lean native asar.splitPath(): it reads the path without
copying when it is ASCII, walks the components once, memoises "is this
*.asar prefix an archive file" by string, and reports when the path has
"."/".."/empty components so the caller can path.normalize() and ask again
(user-supplied paths only; the module loader's are already normalized).

The JS splitPath() is reduced to the type coercion, the /\.asar/i pre-check
that keeps unrelated paths out of native code, the call, and two slices.
Spec coverage for the binding is updated to the new integer contract and a
wrapper-level test for un-normalized archive paths is added.

* test: cover native asar path splitting and archive lookups; fix edge cases

Add an "internals" suite to asar-spec that builds its own archives (nested
directories, link chains and cycles, non-ASCII names, an upper-case .ASAR,
an archive inside a real directory named *.asar, a bogus *.asar file) and
exercises the electron_common_asar binding directly: splitPath() prefix
detection and its normalization contract, Archive.stat/getFileInfo/readdir/
readdirWithTypes/realpath, the lookup memo past its size limit, concurrent
use from worker threads, and the fs wrapper on Buffer/URL/relative/
un-normalized paths.

Fixes found while writing them and by review:
- splitPath() returned a UTF-8 byte offset; report UTF-16 code units so an
  archive under a non-ASCII directory splits correctly.
- Decide "needs normalization" lexically before probing the disk, and ignore
  any number of trailing separators.
- internalModuleStat goes through the normalizing splitPath() again so
  un-normalized NODE_PATH / require.resolve({ paths }) entries keep working.
- Keep separate archive realpath memos for realpath() and realpath.native().
- Skip empty components in the archive header walk.
- Read ELECTRON_LOG_ASAR_READS per call again so it can be set from main.js.

* fix: keep un-openable and not-yet-existing *.asar paths behaving as before

Follow-ups from review of the asar fast path:

- Native splitPath() now rejects paths containing control characters, as the
  base::FilePath converter used by Archive::New() does, so an .asar file under
  such a directory stays an ordinary file instead of an "Invalid package".
- IsDirectoryCached()/IsArchivePrefix() only memoise answers for paths that
  exist. A *.asar path probed before anything is created there (the win32
  mkdir wrapper does this) is re-evaluated later instead of being pinned as
  an archive for the life of the process. This predates the branch for
  IsDirectoryCached().
- getFileInfo() fills the integrity slot with undefined when absent so the
  DictionaryTemplate result keeps using its cached map.
- Spec: create the *.asar-named fixture directory through original-fs, derive
  the realpath.native expectation with the native flavour (8.3 temp paths on
  Windows runners), and cover the cases above.

* perf: read ELECTRON_LOG_ASAR_READS once per process

process.env goes through Node's env interceptor (getenv under a lock) and the
flag was checked on every read of a packed file. Sample it at startup instead
and document that it must be set in the launching environment.
2026-09-14 11:29:32 -07:00

170 lines
5.1 KiB
JavaScript

const minimist = require('minimist');
const cp = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const utils = require('./lib/utils');
const DISABLED_TESTS = require('./node-disabled-tests.json');
const FLAKY_TESTS = require('./node-flaky-tests.json');
const args = minimist(process.argv.slice(2), {
boolean: ['default', 'validateDisabled'],
string: ['jUnitDir']
});
const BASE = path.resolve(__dirname, '../..');
const ROOT_PACKAGE_JSON = path.resolve(BASE, 'package.json');
const OUT_PACKAGE_JSON = path.resolve(path.dirname(utils.getAbsoluteElectronExec()), 'package.json');
const NODE_DIR = path.resolve(BASE, 'third_party', 'electron_node');
const ROOT_STATUS = path.resolve(NODE_DIR, 'test', 'root.status');
const JUNIT_DIR = args.jUnitDir ? path.resolve(args.jUnitDir) : null;
const TAP_FILE_NAME = 'test.tap';
if (!require.main) {
throw new Error('Must call the node spec runner directly');
}
const defaultOptions = [
'tools/test.py',
'-p',
'tap',
'--logfile',
TAP_FILE_NAME,
'--mode=debug',
'default',
`--skip-tests=${DISABLED_TESTS.join(',')}`,
'--flaky-tests=dontcare',
'--measure-flakiness=9',
'--shell',
utils.getAbsoluteElectronExec(),
'-J'
];
// Files outside Electron that are rewritten for the duration of a run and put
// back afterwards. Originals are kept in sibling backup files so an interrupted
// run self-heals next time.
//
// The upstream suite assumes there is no package.json above its test files or
// rooted at process.execPath; Chromium's src/ root and the output directory
// both have a "type": "module" one, which changes module-type detection for
// test files and fixtures and emits MODULE_TYPELESS_PACKAGE_JSON warnings that
// break tests asserting clean stderr. They are removed while the suite runs.
//
// Tests that flake under Electron but should keep running are appended to the
// suite's root.status as PASS,FLAKY, which --flaky-tests=dontcare then reports
// without failing the run.
const REWRITES = [
{ file: ROOT_PACKAGE_JSON, rewrite: () => null },
{ file: OUT_PACKAGE_JSON, rewrite: () => null },
{
file: ROOT_STATUS,
rewrite: (original) => `${original}\n[true]\n${FLAKY_TESTS.map((test) => `${test}: PASS,FLAKY`).join('\n')}\n`
}
];
const backupPath = (file) => `${file}.spec-runner-backup`;
const applyRewrites = () => {
for (const { file, rewrite } of REWRITES) {
// The package.json files won't always exist in CI.
if (!fs.existsSync(file)) {
continue;
}
fs.copyFileSync(file, backupPath(file));
const rewritten = rewrite(fs.readFileSync(file, 'utf8'));
if (rewritten === null) {
fs.rmSync(file);
} else {
fs.writeFileSync(file, rewritten);
}
}
};
const restoreRewrites = () => {
for (const { file } of REWRITES) {
if (!fs.existsSync(backupPath(file))) {
continue;
}
fs.copyFileSync(backupPath(file), file);
fs.rmSync(backupPath(file));
}
};
const getCustomOptions = () => {
let customOptions = ['tools/test.py'];
// Add all custom arguments.
const extra = process.argv.slice(2);
if (extra) {
customOptions = customOptions.concat(extra);
}
// Necessary or Node.js will try to run from out/Release/node.
customOptions = customOptions.concat(['--shell', utils.getAbsoluteElectronExec()]);
return customOptions;
};
async function main() {
// Optionally validate that all disabled specs still exist.
if (args.validateDisabled) {
const missing = [];
for (const test of [...DISABLED_TESTS, ...FLAKY_TESTS]) {
const js = path.join(NODE_DIR, 'test', `${test}.js`);
const mjs = path.join(NODE_DIR, 'test', `${test}.mjs`);
if (!fs.existsSync(js) && !fs.existsSync(mjs)) {
missing.push(test);
}
}
if (missing.length > 0) {
console.error(`Found ${missing.length} missing disabled/flaky specs: \n${missing.join('\n')}`);
process.exit(1);
}
console.log(`All ${DISABLED_TESTS.length} disabled and ${FLAKY_TESTS.length} flaky specs exist.`);
process.exit(0);
}
const options = args.default ? defaultOptions : getCustomOptions();
// Undo a previous interrupted run first, then rewrite for this one and make
// sure everything is put back even if we exit abnormally.
restoreRewrites();
applyRewrites();
process.on('exit', restoreRewrites);
process.on('SIGINT', () => process.exit(130));
process.on('SIGTERM', () => process.exit(143));
const testChild = cp.spawn('python3', options, {
env: {
...process.env,
ELECTRON_RUN_AS_NODE: 'true'
},
cwd: NODE_DIR,
stdio: 'inherit'
});
testChild.on('exit', (testCode) => {
restoreRewrites();
if (JUNIT_DIR) {
fs.mkdirSync(JUNIT_DIR);
const converterStream = require('tap-xunit')();
fs.createReadStream(path.resolve(NODE_DIR, TAP_FILE_NAME))
.pipe(converterStream)
.pipe(fs.createWriteStream(path.resolve(JUNIT_DIR, 'nodejs.xml')))
.on('close', () => {
process.exit(testCode);
});
}
});
}
main().catch((err) => {
console.error('An unhandled error occurred in the node spec runner', err);
process.exit(1);
});