Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: missing stack starting from node 21+ #398

Merged
merged 1 commit into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion lib/clone.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const Utils = require('./utils');


const internals = {
needsProtoHack: new Set([Types.set, Types.map, Types.weakSet, Types.weakMap])
needsProtoHack: new Set([Types.set, Types.map, Types.weakSet, Types.weakMap]),
structuredCloneExists: typeof structuredClone === 'function'
};


Expand Down Expand Up @@ -86,6 +87,16 @@ module.exports = internals.clone = function (obj, options = {}, _seen = null) {
continue;
}

// Can only be covered in node 21+
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

structuredClone is available from v18.x. v21 is the one that breaks the stack property copying old behaviour.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it matter if we use structuredClone early though? I guess it can't hurt, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functionally I would not expect it to, though it might change the performance characteristics. It could be significantly slower (eg. from changing the prototype). Hopefully something like hapijs/boom#304 will be part of the new releases to avoid the copying altogether.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to break boom when using jest, because for jest structuredClone(new Error('test')) instanceof Error is false 🤦🏻‍♂️ Not a problem for everyone, but I expect some bug reports...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to break boom when using jest, because for jest structuredClone(new Error('test')) instanceof Error is false 🤦🏻‍♂️ Not a problem for everyone, but I expect some bug reports...

That does not seem true in general. Especially because we override the prototype if it doesn't match the input. How are you using it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, nvm. The issue is that the VM environment that it runs can cause instanceof Error to just fail.

The primary workaround seems to be not using the VM through jest-light-runner, or alternatively this might work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I had to switch to the latter on my project, it's not difficult but not something I expected to do either. Anyway, I think we can expect some complaints.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, this solution should still work within the VM, as we fix the prototype (I verified that it works under jest), so I'm still not certain why you observe a problem.

Copy link
Contributor Author

@Marsup Marsup Oct 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if it's specific to my computer, but creating an entirely new project with only jest and boom with a super simple test already fails, yet I can't reproduce that in stackblitz for example 🤷🏻‍♂️
image

EDIT: erratum, it obviously happened in our CI as well, so it's not just me. Maybe linux-specific?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured it out! We assume that the cloned class is the same as the original class, which for plain Errors under this kind of VM is not true. I have made a fix for master in #399.

/* $lab:coverage:off$ */
if (internals.structuredCloneExists &&
baseProto === Types.error &&
key === 'stack') {

continue; // Already a part of the base object
}
/* $lab:coverage:on$ */
kanongil marked this conversation as resolved.
Show resolved Hide resolved

const descriptor = Object.getOwnPropertyDescriptor(obj, key);
if (descriptor) {
if (descriptor.get ||
Expand Down Expand Up @@ -160,6 +171,17 @@ internals.base = function (obj, baseProto, options) {

return newObj;
}
// Can only be covered in node 21+
/* $lab:coverage:off$ */
else if (baseProto === Types.error && internals.structuredCloneExists) {
const err = structuredClone(obj); // Needed to copy internal stack state
if (proto !== baseProto) {
Object.setPrototypeOf(err, proto); // Fix prototype
}

return err;
}
/* $lab:coverage:on$ */

if (internals.needsProtoHack.has(baseProto)) {
const newObj = new proto.constructor();
Expand Down
53 changes: 53 additions & 0 deletions test/clone.js
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,59 @@ describe('clone()', () => {
expect(b).to.not.shallow.equal(a);
});

it('clones Error', () => {

class CustomError extends Error {
name = 'CustomError';
}

const a = new CustomError('bad');
a.test = Symbol('test');

const b = Hoek.clone(a);

expect(b).to.equal(a);
expect(b).to.not.shallow.equal(a);
expect(b).to.be.instanceOf(CustomError);
expect(b.stack).to.equal(a.stack); // Explicitly validate the .stack getters
});

it('clones Error with cause', { skip: process.version.startsWith('v14') }, () => {

const a = new TypeError('bad', { cause: new Error('embedded') });
const b = Hoek.clone(a);

expect(b).to.equal(a);
expect(b).to.not.shallow.equal(a);
expect(b).to.be.instanceOf(TypeError);
expect(b.stack).to.equal(a.stack); // Explicitly validate the .stack getters
expect(b.cause.stack).to.equal(a.cause.stack); // Explicitly validate the .stack getters
});

it('clones Error with error message', () => {

const a = new Error();
a.message = new Error('message');

const b = Hoek.clone(a);

//expect(b).to.equal(a); // deepEqual() always compares message using ===
expect(b.message).to.equal(a.message);
expect(b.message).to.not.shallow.equal(a.message);
expect(b.stack).to.equal(a.stack);
});

it('cloned Error handles late stack update', () => {

const a = new Error('bad');
const b = Hoek.clone(a);

a.stack = 'late update';

expect(b).to.equal(a);
expect(b.stack).to.not.equal(a.stack);
});

it('ignores symbols', () => {

const sym = Symbol();
Expand Down