Skip to content

[Bug]: Error converting circular structure to JSON #14840

Open
@benjaminjkraft

Description

Version

v29.7.0, v30.0.0-alpha.2

Steps to reproduce

  1. Create a test file with the below test.
  2. npx jest --workerIdleMemoryLimit=1GB path/to/the.test.js (note: --workerIdleMemoryLimit is just to force Jest to use workers, which you could also trigger by running many tests, --watch, etc.)
  3. test suite crashes with only a circular reference error

Test:

it("tmp", () => {
	const e = new Error("yikes")
	e.e = e
	throw e
})

Expected behavior

I expect to see an error in my code! Which of course happens if you remove e.e = e.

Note that this works fine without workers, and in some cases it works fine even with them (I haven't figured out why.) It seems that in some code paths Jest effectively destructures the error, getting only the fields it will use (i.e. not e) and then passing those over to the parent process, but in others it serializes the whole error, which fails.

Actual behavior

The error is as follows:

 FAIL  ./tmp.test.js
  ● Test suite failed to run

    TypeError: Converting circular structure to JSON
        --> starting at object with constructor 'Error'
        --- property 'e' closes the circle
        at stringify (<anonymous>)

      at messageParent (../../.npm/_npx/fff1c0483a6bf0c3/node_modules/jest-worker/build/index.js:1572:19)

This gives very little information about what actually went wrong. (

Additional context

Of course in a simple repro the answer is simply "don't do that". But in the real cases I started from, I believe the error was likely thrown from third-party code, in an integration test in a large codebase so it's very hard to know. The circular structure in question was a ClientRequest, which could be used in many places. So having no stacktrace from Jest makes it very hard to track down.

For now I've hacked around it with the following patch:

diff --git a/node_modules/jest-worker/build/workers/messageParent.js b/node_modules/jest-worker/build/workers/messageParent.js
index 62e2cce..0083989 100644
--- a/node_modules/jest-worker/build/workers/messageParent.js
+++ b/node_modules/jest-worker/build/workers/messageParent.js
@@ -26,7 +26,28 @@ function messageParent(message, parentProcess = process) {
       message
     ]);
   } else if (typeof parentProcess.send === 'function') {
-    parentProcess.send([_types.PARENT_MESSAGE_CUSTOM, message]);
+    try {
+      parentProcess.send([_types.PARENT_MESSAGE_CUSTOM, message]);
+    } catch (e) {
+      if (!e.message.includes("circular structure to JSON")) {
+        throw e
+      }
+      // These errors are super annoying to debug; remove some information from
+      // the error to try to get it through to the parent process.
+      const { failureDetails } = message[1][1]
+      failureDetails.forEach((err, i) => {
+        // Keep the error message+stack, but drop other fields.
+        failureDetails[i] = new Error(e.message + "\nOriginal error was: " + err.message)
+        if (err.stack) {
+          failureDetails[i].stack = err.stack
+        }
+      })
+      try {
+        parentProcess.send([_types.PARENT_MESSAGE_CUSTOM, message]);
+      } catch (e2) {
+        throw e
+      }
+    }
   } else {
     throw new Error('"messageParent" can only be used inside a worker');
   }
diff --git a/node_modules/jest-worker/build/workers/processChild.js b/node_modules/jest-worker/build/workers/processChild.js
index 1a47f23..0100599 100644
--- a/node_modules/jest-worker/build/workers/processChild.js
+++ b/node_modules/jest-worker/build/workers/processChild.js
@@ -79,7 +79,30 @@ function reportSuccess(result) {
   if (!process || !process.send) {
     throw new Error('Child can only be used on a forked process');
   }
-  process.send([_types.PARENT_MESSAGE_OK, result]);
+  try {
+    process.send([_types.PARENT_MESSAGE_OK, result]);
+  } catch (e) {
+    if (!e.message.includes("circular structure to JSON")) {
+      throw e
+    }
+    result.testResults.forEach(testResult => {
+      // These errors are super annoying to debug; remove some information from
+      // the error to try to get it through to the parent process.
+      const { failureDetails } = testResult
+      failureDetails.forEach((err, i) => {
+        // Keep the error message+stack, but drop other fields.
+        failureDetails[i] = new Error(e.message + "\nOriginal error was: " + err.message)
+        if (err.stack) {
+          failureDetails[i].stack = err.stack
+        }
+      })
+    })
+    try {
+      process.send([_types.PARENT_MESSAGE_OK, result]);
+    } catch (e2) {
+      throw e
+    }
+  }
 }
 function reportClientError(error) {
   return reportError(error, _types.PARENT_MESSAGE_CLIENT_ERROR);

Probably the more robust approach is something more like what reportError does, but this was easier to hack in.

See also specific cases that may be the same/similar issues: #11958, #10577

Environment

System:
    OS: macOS 14.2.1
    CPU: (10) arm64 Apple M1 Max
  Binaries:
    Node: 18.17.0 - /nix/store/3pmw2p5lhkl6g572n1gc2la32x97815c-nodejs-18.17.0/bin/node
    npm: 9.6.7 - ~/.local/bin/npm
  npmPackages:
    jest: ^29.7.0 => 29.7.0

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions