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

feature: remove listeners on stop #244

Merged
merged 12 commits into from
Jul 30, 2020
Merged

feature: remove listeners on stop #244

merged 12 commits into from
Jul 30, 2020

Conversation

marcosvega91
Copy link
Member

Hey I have partially closed #217.

When the user run woker.stop() all listeners attached will be removed.
I noticed that activeMocking and requestIntegrityCheck were useful only
at the beginning, so after resolve/reject I have removed both of them.

I have added a new test to be sure that everything work as I'm expected

Copy link
Member

@kettanaito kettanaito left a comment

Choose a reason for hiding this comment

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

Thank you for these changes, @marcosvega91! I've given them a look, have a few suggestions. Let me know what you think about those.

src/setupWorker/start/createStart.ts Outdated Show resolved Hide resolved
if (worker.state !== 'redundant') {
// Notify the Service Worker that this client has closed.
// Internally, it's similar to disabling the mocking, only
// client close event has a handler that self-terminates
// the Service Worker when there are no open clients.
worker.postMessage('CLIENT_CLOSED')
}
}
window.addEventListener('beforeunload', beforeUnload)
Copy link
Member

Choose a reason for hiding this comment

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

If we adopt the context.addListener() pattern, this can be simplified, as we won't have to repeat the beforeUnload twice.

Copy link
Member Author

Choose a reason for hiding this comment

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

I have only this doubt, why you said that we do this twice?

src/setupWorker/stop/createStop.ts Outdated Show resolved Hide resolved
test/msw-api/setup-worker/clearListeners.test.ts Outdated Show resolved Hide resolved
@kettanaito kettanaito added this to the 0.20.0 milestone Jul 1, 2020
Copy link
Contributor

@cowboyd cowboyd left a comment

Choose a reason for hiding this comment

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

I'm looking to integrate msw with BigTest and running into this issue. Our use-case is that we have two frames that share an origin: a control frame where test instructions are run, and an app frame that actually contains the application under test.

This is causing instances to pile up in our control frame in between test cases. Is there anything I can do to push this forward?

@kettanaito I've added some thoughts to the PR, and would be happy to push this across the finish line since it would be very beneficial for us to land this.

@@ -8,6 +8,14 @@ export interface ComposeMocksInternalContext {
worker: ServiceWorker | null
registration: ServiceWorkerRegistration | null
requestHandlers: RequestHandler<any, any>[]
events: {
Copy link
Contributor

@cowboyd cowboyd Jul 23, 2020

Choose a reason for hiding this comment

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

why stuff this into an events namespace?

context.addListener()
context.removeAllListers()

seems a lot more simple and in keeping with most event listener apis out there. Putting it under events seems like overkill especially since the events object isn't peeled off anywhere and passed around of its own accord.

Copy link
Member Author

Choose a reason for hiding this comment

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

it makes sense but we did in this way to separate events from other context parameters

Copy link
Member

Choose a reason for hiding this comment

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

I don't see how grouping the events-related properties/methods in the events key is an overkill. It's perfectly fine, I'd leave it like that. It makes events references from the context much more apparent, and gives us a single point of control if we decide to refactor this functionality in the future.

if (worker.state !== 'redundant') {
// Notify the Service Worker that this client has closed.
// Internally, it's similar to disabling the mocking, only
// client close event has a handler that self-terminates
// the Service Worker when there are no open clients.
worker.postMessage('CLIENT_CLOSED')
}
})
}
context.events.add(window, 'beforeunload', beforeUnload)
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of using a separate callback, why not just call stop() at this point. This guarantees that there is a single codepath for starting and stopping, and that stopping happens automatically on a window beforeunload.

Comment on lines 9 to 8
let listeners: MSWEventListener[]
const removeLiteners = () => {
listeners.forEach((listener) =>
listener.handler.removeEventListener(listener.type, listener.listener),
)
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Rather than re-invent the wheel here, wouldn't it make sense to just pass in the context to activateMocking? That way, we can re-use the automated teardown logic from there.

In fact, since in this PR, both are actually adding glue to remove listeners added to window by createBroadCastChannel at the time the context is stopped, and these are the only cases of using createBroadCastChannel, wouldn't it make sense to remove createBroadCastChannel entirely and just use this mechanism?

Copy link
Member Author

Choose a reason for hiding this comment

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

I have done in this way because activateMocking listeners are removed asap, when the promise is resolved. So I thought to not add to context listeners

Copy link
Contributor

Choose a reason for hiding this comment

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

Even in that case, I think it's best to still put that onto the context in so that it can re-use the global teardown in the event that the window is torn down, or stop is called before the immediate message is received.

For example, I believe there is a race condition that will happen if:

  1. addMessagelistener() is called
  2. user calls stop()
  3. message is received.

In that case, the listener will still be dispatched when it shouldn't be.

I think we can have the best of both worlds by implementing a once() method on the context, similar to EventEmitter#once which lets us have the best of both worlds. It removes itself immediately, but it also uses the context-bound event registration mechanism that won't overstay its welcome.

Here is an example implementation https://github.com/cowboyd/msw/blob/82fa2ad486f0030e6e4fb9f352f295cedb45de92/src/setupWorker/setupWorker.ts#L62

As a bonus, I made it return a promise so that you can now use simple async functions to work with message dispatch and response https://github.com/cowboyd/msw/blob/82fa2ad486f0030e6e4fb9f352f295cedb45de92/src/setupWorker/setupWorker.ts#L62-L100

Where this approach really shines is in things like the integrity check which can now be written as a simple async function. Thus this:

export function requestIntegrityCheck(
  serviceWorker: ServiceWorker,
): Promise<ServiceWorker> {
  let listeners: MSWEventListener[]
  // Signal Service Worker to report back its integrity
  serviceWorker.postMessage('INTEGRITY_CHECK_REQUEST')
  const removeLiteners = () => {
    listeners.forEach((listener) =>
      listener.handler.removeEventListener(listener.type, listener.listener),
    )
  }
  return new Promise((resolve, reject) => {
    listeners = addMessageListener(
      'INTEGRITY_CHECK_RESPONSE',
      (message) => {
        const { payload: actualChecksum } = message

        // Compare the response from the Service Worker and the
        // global variable set by webpack upon build.
        if (actualChecksum !== SERVICE_WORKER_CHECKSUM) {
          return reject(
            new Error(
              `Currently active Service Worker (${actualChecksum}) is behind the latest published one (${SERVICE_WORKER_CHECKSUM}).`,
            ),
          )
        }
        removeLiteners()
        resolve(serviceWorker)
      },
      () => {
        removeLiteners()
        reject()
      },
    )
  })
}

becomes this:

export async function requestIntegrityCheck(
  context: SetupWorkerInternalContext,
  serviceWorker: ServiceWorker,
): Promise<ServiceWorker> {
  // Signal Service Worker to report back its integrity
  serviceWorker.postMessage('INTEGRITY_CHECK_REQUEST')

  const { payload: actualChecksum } = await context.once('INTEGRITY_CHECK_RESPONSE')

  // Compare the response from the Service Worker and the
  // global variable set by webpack upon build.
  if (actualChecksum !== SERVICE_WORKER_CHECKSUM) {
    throw new Error(
      `Currently active Service Worker (${actualChecksum}) is behind the latest published one (${SERVICE_WORKER_CHECKSUM}).`,
    )
  }
  return serviceWorker
}

Which seems like a pretty nice win.

@cowboyd
Copy link
Contributor

cowboyd commented Jul 23, 2020

@marcosvega91 @kettanaito fwiw, I've implemented these suggestions here master...cowboyd:pr/remove_listeners_on_stop

@marcosvega91
Copy link
Member Author

marcosvega91 commented Jul 23, 2020

for me suggestions are valid 👍 . Thanks for your time on this 😄

@kettanaito
Copy link
Member

I'm stunned by the quality of work you've done here, folks. I'm cherry-picking the commits from @cowboyd into your pull request, @marcosvega91, and will provide some polishing on top.

@kettanaito
Copy link
Member

kettanaito commented Jul 24, 2020

I've added the ability for .addListener() function to return a clean up function, so it's easier to associate an event listener with its clean up counterpart. Other than that, just minor changes here and there.

Trying to get the test/msw-api/setup-worker/stop/removes-all-listeners.test.ts test to run, but it fails with the following error:

Protocol error (Runtime.callFunctionOn): Execution context was destroyed.

This most likely means that the runtime.page reference in runtime.page.evaluate() has been destroyed and cannot be referenced anymore. This happens if the page navigated elsewhere between its declaration and the .evaluate() call. Isn't reproducible manually by yarn test:focused test/msw-api/setup-worker/stop/removes-all-listeners.mocks.ts.

While I can verify that the test scenario works manually, we need to get its automated run passing as well. I'd appreciate if you could look into that test, perhaps together we can spot what's wrong. Thanks.

@marcosvega91
Copy link
Member Author

Thanks @kettanaito for the help. I'll look into it tomorrow because today i'm not at home unfortunately

@cowboyd
Copy link
Contributor

cowboyd commented Jul 24, 2020

@kettanaito I'm also trying to have a look, but don't seem able to run the integration tests. I'm consistently getting this error (on both node 12, and 14):

$ yarn test:integration
yarn run v1.22.4
$ node --max_old_space_size=8000 node_modules/.bin/jest --config=test/jest.config.js --runInBand

 RUNS  test/rest-api/request-matching/uri.test.ts
Assertion failed: (0), function uv_close, file ../deps/uv/src/unix/core.c, line 174.
error Command failed with signal "SIGABRT".

@cowboyd
Copy link
Contributor

cowboyd commented Jul 24, 2020

I'm consistently getting this error (on both node 12, and 14):

Oops, didn't see this #188

@cowboyd
Copy link
Contributor

cowboyd commented Jul 24, 2020

So strange:

This test passes for me locally.

 PASS  test/msw-api/setup-worker/stop/removes-all-listeners.test.ts
  ● Console

    console.log

      Loaded mock definition:
      /Users/cowboyd/Code/@bigtest/msw/test/msw-api/setup-worker/stop/removes-all-listeners.mocks.ts

      Resolved "msw" module to:
      /Users/cowboyd/Code/@bigtest/msw

      Using Service Worker build:
      /Users/cowboyd/Code/@bigtest/msw/lib/esm/mockServiceWorker.js

      at Object.<anonymous>.exports.spawnServer (support/spawnServer.ts:28:11)

    console.log

      Server established at http://localhost:52500

      at Server.<anonymous> (support/spawnServer.ts:116:15)

@cowboyd
Copy link
Contributor

cowboyd commented Jul 24, 2020

I'm a bit confused, I can't reproduce the test failure locally, and it looks like what's failing in CI is actually delay.test.ts

@kettanaito do you think these are related?

Summary of all failing tests
 FAIL  msw-api/context/delay.test.ts
  ● uses realistic server response delay, when not provided

    expected 401 to be roughly equal to 250 (deviation: 150)

      79 |   // of the random realistic response time.
      80 |   const responseTime = endPerf - startPerf
    > 81 |   expect(responseTime).toRoughlyEqual(250, 150)
         |                        ^
      82 | 
      83 |   const status = res.status()
      84 |   const headers = res.headers()

      at msw-api/context/delay.test.ts:81:24
      at fulfilled (msw-api/context/delay.test.ts:5:58)
          at runMicrotasks (<anonymous>)

@kettanaito
Copy link
Member

@cowboyd, the delay test scenario is currently flaky (wrong range delta), should be addressed in #276. You can ignore it failing for time being.

Strange that the listeners removal scenario passes for you. Please, which version of NodeJS are you running? The repository is built on top of v12.18.0.

@cowboyd
Copy link
Contributor

cowboyd commented Jul 24, 2020

@kettanaito Unless I'm mistaken, it looks like the delay scenario is the only one failing CI as well https://app.circleci.com/pipelines/github/mswjs/msw/754/workflows/d90c1d91-8c35-4c4a-9f85-cfd438b7e72a/jobs/1129/parallel-runs/0/steps/0-103

I've tested on node@12 and node@14 with the result that all integration tests pass. 🤷

@marcosvega91
Copy link
Member Author

I have tried on my local env and all tests are passing using node@12

@marcosvega91
Copy link
Member Author

The issue on CI I think is regarding to this #276

@marcosvega91
Copy link
Member Author

I have simply run again the pipeline and not it's working. We have to move forward on the other issue to solve definitively

@kettanaito
Copy link
Member

Thank you for running that test locally, fellows. I will try to run it once more. Wonder what fails it for me.

@marcosvega91
Copy link
Member Author

I'm not sure but I think that the issue that you had could be regarding to #188. I'm not sure but it could make sense

@kettanaito
Copy link
Member

kettanaito commented Jul 28, 2020

Rebased upon the latest master that includes the fix for #188, still experiencing a failed test:

$ yarn test:integration msw-api/setup-worker/stop/removes-all-listeners.test.ts

 FAIL  test/msw-api/setup-worker/stop/removes-all-listeners.test.ts
  ✕ removes all listeners when the worker is stopped (44ms)

  ● removes all listeners when the worker is stopped

    Protocol error (Runtime.callFunctionOn): Execution context was destroyed.

      20 |   })
      21 |
    > 22 |   await runtime.page.evaluate(() => {
         |                      ^
      23 |     // @ts-ignore
      24 |     const worker1 = window.window.__MSW_CREATE_WORKER__()
      25 |     // @ts-ignore

      at ../node_modules/puppeteer/lib/Connection.js:154:63
      at CDPSession.send (../node_modules/puppeteer/lib/Connection.js:153:16)
      at ExecutionContext._evaluateInternal (../node_modules/puppeteer/lib/ExecutionContext.js:77:50)
      at ExecutionContext.evaluate (../node_modules/puppeteer/lib/ExecutionContext.js:32:27)
      at ExecutionContext.<anonymous> (../node_modules/puppeteer/lib/helper.js:83:27)
      at DOMWorld.evaluate (../node_modules/puppeteer/lib/DOMWorld.js:111:24)
        -- ASYNC --
      at Frame.<anonymous> (../node_modules/puppeteer/lib/helper.js:82:19)
      at Page.evaluate (../node_modules/puppeteer/lib/Page.js:792:47)
      at Page.<anonymous> (../node_modules/puppeteer/lib/helper.js:83:27)
      at msw-api/setup-worker/stop/removes-all-listeners.test.ts:22:22
      at msw-api/setup-worker/stop/removes-all-listeners.test.ts:8:71
      at Object.<anonymous>.__awaiter (msw-api/setup-worker/stop/removes-all-listeners.test.ts:4:12)
      at Object.<anonymous> (msw-api/setup-worker/stop/removes-all-listeners.test.ts:15:69)

If this passes on the CI, I'll dive into the difference between CI and my local machine. So far the versions of node/etc. should be the same.

What is apparent is that the puppeteer dependency is quite outdated (two major versions behind):

puppeteer                        3.0.2   3.3.0    5.2.1

I wonder if this can be causing any trouble. However, it works for you, folks, and I presume you are running the same old version of Puppeteer.

@kettanaito
Copy link
Member

I've tried to make msw-api/setup-worker/stop/removes-all-listeners.test.ts pass as hard as I could, but it keeps failing for me locally. It produces flaky results, with asserts console messages sometimes being empty, sometimes just 1 activation message, and never 2.

There is definitely an evaluation issue here, as it seems whenever I try to call workerN.start() it fails, where N is any worker rather than the first activated one. This might've made sense if calling worker.start() refreshed the page, but it doesn't, so the evaluation context should remain the same. However, Puppeteer clearly states that the context was destroyed.

Although the test passes on CI, I cannot treat it as stable until I reproduce it locally. I'm leaning towards having this test suite skipped. @marcosvega91, do you have any suggestions in my case, please?

@marcosvega91
Copy link
Member Author

Ok now I have the issue like you. As you can see also the CI is failing

@kettanaito
Copy link
Member

I'm marking this test as skipped and referencing this PR.

@marcosvega91
Copy link
Member Author

I have fixed the test, when you merge from master you changed startGroupCollapsed with log

@kettanaito
Copy link
Member

@marcosvega91, I'm terribly sorry, just forced pushed. Could you please push that fix commit once more? 🙏

@marcosvega91
Copy link
Member Author

It is not a problem 😃 . I have pushed the fix 🥳 , let me know

@kettanaito
Copy link
Member

Thank you! I'm preserving that test, but marking it as skipped. It's evident enough to come back to it later and try to resolve it. I'd start from bumping dependencies versions before the next release, perhaps there's some behavior change in Puppeteer.

Thank you for the awesome work!

@kettanaito
Copy link
Member

kettanaito commented Jul 30, 2020

There's one issue I'm experiencing that's not necessarily related to these changes, but I'd like to investigate it before merging this to be safe.

Issue reproduction

  1. Checkout the NextJS example.
  2. Go to examples/with-msw.
  3. Install the dependencies.
  4. Link the local build of msw package as of 442bd112e5efdcfe57cfea217c74efd0cbda71a6 commit.
  5. Run yarn dev.
  6. Open the app in the browser.
  7. Go to mocks/handlers.js and change any mocked response value.
  8. See the request logs doubled after Fast refresh.

Issue description

It appears that although we are clearing the listeners after unload/.stop, the message event handler still persists after Fast refresh. I believe that is because Fast refresh doesn't trigger page unload.

If you observe the browser logs, you can see that the first response contains the old data (before refresh), while the second response contains the updated data. However, since the first response is delivered first, it's being used in the app.

Do you have some thoughts on how to tackle this?

I have a suspicion that if we remove the listener of this event on Fast reload it would work:

const remove = context.events.addListener(
  navigator.serviceWorker,
  'message',
  handleRequestWith(context, resolvedOptions),
)

if (FAST_REFRESH) {
  remove()
}

The question is how to know that Fast refresh is at place?

@kettanaito
Copy link
Member

kettanaito commented Jul 30, 2020

How I'm able to solve that is by keeping a global reference to the message event handler, and checking if it exists on each start call:

let remove: any

export const createStart = (context: SetupWorkerInternalContext) => {
  /**
   * Registers and activates the mock Service Worker.
   */
  return function start(options?: StartOptions) {
      // ...

      if (remove) {
        console.warn('Code evaluated again, but the listener is present. Remove.')
        remove()
      }

      // Store the global reference.
      remove = context.events.addListener(
        navigator.serviceWorker,
        'message',
        handleRequestWith(context, resolvedOptions),
      )

Since Fast refresh re-evaluates function's scope, if you declare remove within createStart or start, it will get assigned to undefined on refresh. Only by keeping it on the global scope we can persist the reference between Fast refreshes.

However, I dislike this approach because an obsolete event handler issue on Fast refresh is affecting all the handlers, not just this one. It seems this should be solved on the context.events.addListener() level somehow. We can keep the list of listeners globally in a similar fashion, however, how do you ensure that the listener already exists? It's like we need to re-created the logic of removeEventListener where it compares both event name and listener function reference.

@kettanaito
Copy link
Member

Fast Refresh solution

What I found to be consistently solving the Fast refresh issue:

  • Declare the list of listeners on the module's scope.
  • Call context.events.removeAllListeners() as the first thing in the start() function of the worker. This way when the worker starts (regularly and as a part of Fast refresh) it prunes all the listeners, because they will be attached again later in the start function.

@kettanaito
Copy link
Member

I'm also experiencing a stale data issue on the server-side of Next JS (with getServerSideProps), but it doesn't seem related to these changes, so it will be addressed separately.

Copy link
Member

@kettanaito kettanaito left a comment

Choose a reason for hiding this comment

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

Thank you for the superb changes, @marcosvega91, @cowboyd! Approved.

@kettanaito kettanaito merged commit e389d04 into mswjs:master Jul 30, 2020
@marcosvega91 marcosvega91 deleted the pr/remove_listeners_on_stop branch July 30, 2020 09:32
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.

Stopping and starting MSW will cause multiple "instances"
3 participants