Skip to content

🐛 Bug: parallel mode serializer mutates objects reachable from a test's error in place, corrupting shared modules for the reused worker and losing the file's results #6053

Description

@GrahamCampbell

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:

  1. 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.
  2. 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>).
  3. 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-reproducernpm 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.

Metadata

Metadata

Assignees

Labels

major: v11status: in triagea maintainer should (re-)triage (review) this issuetype: buga defect, confirmed by a maintainer

Type

No type
No fields configured for issues without a type.

Projects

Status
No status

Relationships

None yet

Development

No branches or pull requests

Issue actions