Bug Report Checklist
Expected
In --parallel mode, a failing test whose error has extra properties attached is reported the same as in serial mode: the failure appears in the output, objects reachable from the error are left unmodified, and later files in the same worker are unaffected.
Actual
If the error's property graph reaches an object with function-valued properties (e.g. a built-in module's exports), Mocha's worker-side serializer mutates the original objects instead of a copy, and three things go wrong at once:
- Shared modules are corrupted. The serializer deletes every function it encounters from the original objects, so
require('crypto') permanently loses randomBytes, createHash, etc. Workers are reused across files, so unrelated tests then fail with crypto.randomBytes is not a function.
- The file's entire result batch is lost. The walk reaches
crypto.getRandomValues, a non-configurable accessor, and the strict-mode delete throws. The original test failure is never reported; the only trace is an Uncaught error outside test suite whose filtered stack is the single line at Array.forEach (<anonymous>).
- Failures land on innocent tests that merely shared the worker.
Serial mode reports the same failure correctly and leaves crypto intact.
Reproduction
See GrahamCampbell/mocha-reproducer — npm install && npm test. It contains one spec that throws an error referencing require('crypto'), four identical victim specs that assert crypto.randomBytes is still a function, and npm run smoke, which triggers the defect through mocha/lib/nodejs/serializer directly with no test runner involved. The README covers the run modes and the --jobs ≥ 2 caveat.
Reaching a built-in module from an error is realistic: aws-sdk v2 exposes AWS.util.crypto.lib = require('crypto'), so any error referencing an SDK-using application object has such a path.
Root cause
SerializableEvent#serialize() in lib/nodejs/serializer.js walks the event's data and error graphs in place (its own doc comment says it "Modifies this object in place"), and _serialize executes delete parent[key] on the original object for every function-valued property (lines 227–231, "for now, just zap it"). The throw escapes through SerializableWorkerResult#serialize()'s this.events.forEach(...), the one frame that survives Mocha's stack filtering.
Suggested fix: serialize into fresh structures instead of mutating the walked objects. Minimal alternative: consult property descriptors instead of reading values (reading invokes getters), skip non-configurable properties, and wrap the delete in try/catch so one bad property can't destroy a file's results.
Two sibling hazards live in the same path — breakCircularDeps (lib/utils.js, from #4552) also mutates originals, and the walk dedupes visited properties by key name alone, skipping same-named keys on different objects — happy to file those separately.
Versions
Hit on 12.0.0-beta-9.2; the serializer logic is identical in 11.7.6 (stable), 12.0.0-beta-9.5 (serializer.mjs), and beta-10, so this dates to the original parallel-mode implementation, not the betas. Node 22 (Windows CI) and 24.14.1 (macOS), CommonJS specs, default spec reporter, no transpilers.
Bug Report Checklist
npm audit, or GitHub Advisory issue.faqlabel, but none matched my issue.Expected
In
--parallelmode, a failing test whose error has extra properties attached is reported the same as in serial mode: the failure appears in the output, objects reachable from the error are left unmodified, and later files in the same worker are unaffected.Actual
If the error's property graph reaches an object with function-valued properties (e.g. a built-in module's exports), Mocha's worker-side serializer mutates the original objects instead of a copy, and three things go wrong at once:
require('crypto')permanently losesrandomBytes,createHash, etc. Workers are reused across files, so unrelated tests then fail withcrypto.randomBytes is not a function.crypto.getRandomValues, a non-configurable accessor, and the strict-modedeletethrows. The original test failure is never reported; the only trace is anUncaught error outside test suitewhose filtered stack is the single lineat Array.forEach (<anonymous>).Serial mode reports the same failure correctly and leaves
cryptointact.Reproduction
See GrahamCampbell/mocha-reproducer —
npm install && npm test. It contains one spec that throws an error referencingrequire('crypto'), four identical victim specs that assertcrypto.randomBytesis still a function, andnpm run smoke, which triggers the defect throughmocha/lib/nodejs/serializerdirectly with no test runner involved. The README covers the run modes and the--jobs ≥ 2caveat.Reaching a built-in module from an error is realistic: aws-sdk v2 exposes
AWS.util.crypto.lib = require('crypto'), so any error referencing an SDK-using application object has such a path.Root cause
SerializableEvent#serialize()inlib/nodejs/serializer.jswalks the event'sdataanderrorgraphs in place (its own doc comment says it "Modifies this object in place"), and_serializeexecutesdelete parent[key]on the original object for every function-valued property (lines 227–231, "for now, just zap it"). The throw escapes throughSerializableWorkerResult#serialize()'sthis.events.forEach(...), the one frame that survives Mocha's stack filtering.Suggested fix: serialize into fresh structures instead of mutating the walked objects. Minimal alternative: consult property descriptors instead of reading values (reading invokes getters), skip non-configurable properties, and wrap the
deletein try/catch so one bad property can't destroy a file's results.Two sibling hazards live in the same path —
breakCircularDeps(lib/utils.js, from #4552) also mutates originals, and the walk dedupes visited properties by key name alone, skipping same-named keys on different objects — happy to file those separately.Versions
Hit on 12.0.0-beta-9.2; the serializer logic is identical in 11.7.6 (stable), 12.0.0-beta-9.5 (
serializer.mjs), and beta-10, so this dates to the original parallel-mode implementation, not the betas. Node 22 (Windows CI) and 24.14.1 (macOS), CommonJS specs, default spec reporter, no transpilers.