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(ClientRequest): prevent stacked proxies when intercepting node http(s) requests #697

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

sam-super
Copy link
Contributor

@sam-super sam-super commented Jan 3, 2025

It was possible to end up with the http(s).request/get proxies wrapping existing proxies, which can cause odd behaviour:
nock/nock#2802

This PR attempts to fix the issue by only creating a proxy once, and just updating the callbacks the proxy uses when it's called a second time. it's not idea (since it could cause confusion with the previous instance, which will now no longer be invoked), but it seems like a preferable scenario.

instead we could error when an existing proxy is detected, but it seems like it would cause a lot of errors in practice (due to nodes module loader cache being inconsistent).

…ing node http(s) requests

It was possible to end up with the http.request (etc) proxies wrapping existing proxies, which can
cause odd behaviour.
@sam-super
Copy link
Contributor Author

Not sure why but some tests failed when running locally on macos (node 18). they all seem to be due to header being lowercase on my machine:
image
any idea why?

Add logging so we know when we update an existing proxy and fix some linting (remove semicolons)
@mikicho
Copy link
Contributor

mikicho commented Jan 5, 2025

How does the code pass the check for an already applied interceptor? It seems like this is the problem we need to solve.

@sam-super
Copy link
Contributor Author

How does the code pass the check for an already applied interceptor? It seems like this is the problem we need to solve.

I'm doing some more digging, but the core problem/discrepancy seems to come from this scenario:

  1. Interceptors have been setup and they work
  2. MSW's apply is executed again
  3. globalThis state has been lost (so the global symbols are lost, and the interceptors are re-initialised).
  4. fetch isn't affected (presumably because it's been reset along with the symbols), so MSW just re-patches it
  5. however, the core node:http(s) modules are persisting the patch, which causes the Proxy-stacking.

How we end up with globalThis being reset I'm not sure. So we can narrow it down I'm trying to replicate it without including the jest/nock. Will update...

@mikicho
Copy link
Contributor

mikicho commented Jan 6, 2025

@sam-super, I think Jest contributes to this by creating a test sandbox. It may wipe out globalThis but not the module cache.

@sam-super
Copy link
Contributor Author

@mikicho yup just tested it and globalThis is reset between each test.
I guess it's then node env:
https://github.com/jestjs/jest/blob/main/packages/jest-environment-node/src/index.ts

@sam-super
Copy link
Contributor Author

So MSW's attempt to use globalThis to maintain a single instance of each interceptor wont work in jest via nodejs.

Because the FetchInterceptor is bound to globalFetch, it currently behaves differently from ClientRequestInterceptor (which is bound to the http module cache).

Ideally maybe jest should reset the native-module cache between each test to remove any state that is attached (i.e. the interceptor proxy). Not sure how feasible it will be though. From my testing jest's own jest.resetModules(); method doesn't fix the issue (the proxy persists between the tests).

I know this PR isn't ideal, but i think preventing the stacking of proxies is probably still a good thing, and if MSW errors on detecting the proxy, it might break a lot of code that uses MSW in node/jest, so updating the proxy to the latest seems the least-bad way IMO.

@kettanaito
Copy link
Member

Hi, @sam-super. Thanks for proposing this.

I would like to start from the beginning and have an isolated failing test for this scenario. Ideally, without using Jest.

This bit concerns me:

globalThis state has been lost (so the global symbols are lost, and the interceptors are re-initialised).

If your environment gets globalThis wiped out, you have bigger problems to care about then Interceptors applying twice. May be one of those Jest shenanigans we shouldn't tailor to. This is where a good automated test is crucial.

So MSW's attempt to use globalThis to maintain a single instance of each interceptor wont work in jest via nodejs.

Do you have a test to prove this? MSW has been used with Jest for years, I don't believe this has been a problem. The root cause may be elsewhere for all I can see. MSW applies itself per process. Process cannot really change its globals. If it does, that's an unexpected behavior in whichever agent makes it so.

So, if we can reproduce this in isolation without Jest, I am up to discussing the fix. If not, I will treat this as a Jest-specific shenanigan we won't cover. MSW doesn't officially support Jest since last October.

@mikicho
Copy link
Contributor

mikicho commented Jan 31, 2025

@kettanaito I investigated this. I'm pretty sure it's because jest reset all modules except the builtin modules.

@kettanaito
Copy link
Member

@mikicho, thanks for an update. And that happens while the test runs? Have you found the reasoning behind this behavior?

@mikicho
Copy link
Contributor

mikicho commented Jan 31, 2025

Have you found the reasoning behind this behavior?

It's jest feature to isolate tests between files in runInBand mode.

@sam-super
Copy link
Contributor Author

Have you found the reasoning behind this behavior?

It's jest feature to isolate tests between files in runInBand mode.

I haven't needed to set runInBand to cause this issue.

@sam-super
Copy link
Contributor Author

@mikicho @kettanaito this is a repo i put together to demonstrate how jest resets global state and how the proxy MSW creates persists regardless:
https://github.com/sam-super/msw-proxy-bug

@mikicho
Copy link
Contributor

mikicho commented Jan 31, 2025

I haven't needed to set runInBand to cause this issue.

You run one worker, it's practically the same. in both cases, jest has only one process for all test files.

@sam-super
Copy link
Contributor Author

I haven't needed to set runInBand to cause this issue.

You run one worker, it's practically the same. in both cases, jest has only one process for all test files.

It's true in this specific case, and i have set a single worker to demonstrate the issue, but because jest re-uses worker threads, i don't think you need to set --runInBand or --workers=1 to cause the issue. When a second test-suite is executed in a worker that has already been used to setup an MSW interceptor, it will exhibit the same proxy-stacking behaviour if you setup MSW again.

@sam-super
Copy link
Contributor Author

I didn't think the example repo (https://github.com/sam-super/msw-proxy-bug) was clear enough in what it was doing (due to the tests passing). I updated it so the tests fail in an attempted to make it clearer what is going on.

It now has an assertion based on a few incorrect assumption of global state in jest and shows how it causes MSW to create multiple proxies:
image
I hope it helps to clarify things.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants