Просмотр исходного кода

fix(runtime-core): prevent instance leak in withAsyncContext (#14445)

fix nuxt/nuxt#33644
edison 1 месяц назад
Родитель
Сommit
702284f6a7

+ 82 - 0
packages/runtime-core/__tests__/apiSetupHelpers.spec.ts

@@ -250,6 +250,86 @@ describe('SFC <script setup> helpers', () => {
       expect(serializeInner(root)).toBe('hello')
     })
 
+    test('should not leak instance to user microtasks after restore', async () => {
+      let leakedToUserMicrotask = false
+
+      const Comp = defineComponent({
+        async setup() {
+          let __temp: any, __restore: any
+          ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
+          __temp = await __temp
+          __restore()
+
+          Promise.resolve().then(() => {
+            leakedToUserMicrotask = getCurrentInstance() !== null
+          })
+
+          return () => ''
+        },
+      })
+
+      const root = nodeOps.createElement('div')
+      render(
+        h(() => h(Suspense, () => h(Comp))),
+        root,
+      )
+
+      await new Promise(r => setTimeout(r))
+      expect(leakedToUserMicrotask).toBe(false)
+    })
+
+    test('should not leak sibling instance in concurrent restores', async () => {
+      let resolveOne: () => void
+      let resolveTwo: () => void
+      let done!: () => void
+      let pending = 2
+      const ready = new Promise<void>(r => {
+        done = r
+      })
+      const seenUid: Record<'one' | 'two', number | null> = {
+        one: null,
+        two: null,
+      }
+
+      const makeComp = (name: 'one' | 'two', wait: Promise<void>) =>
+        defineComponent({
+          async setup() {
+            let __temp: any, __restore: any
+            ;[__temp, __restore] = withAsyncContext(() => wait)
+            __temp = await __temp
+            __restore()
+
+            Promise.resolve().then(() => {
+              seenUid[name] = getCurrentInstance()?.uid ?? null
+              if (--pending === 0) done()
+            })
+
+            return () => ''
+          },
+        })
+
+      const oneReady = new Promise<void>(r => {
+        resolveOne = r
+      })
+      const twoReady = new Promise<void>(r => {
+        resolveTwo = r
+      })
+      const CompOne = makeComp('one', oneReady)
+      const CompTwo = makeComp('two', twoReady)
+
+      const root = nodeOps.createElement('div')
+      render(
+        h(() => h(Suspense, () => h('div', [h(CompOne), h(CompTwo)]))),
+        root,
+      )
+
+      resolveOne!()
+      resolveTwo!()
+      await ready
+      expect(seenUid.one).toBeNull()
+      expect(seenUid.two).toBeNull()
+    })
+
     test('error handling', async () => {
       const spy = vi.fn()
 
@@ -295,6 +375,8 @@ describe('SFC <script setup> helpers', () => {
       expect(spy).toHaveBeenCalled()
       // should retain same instance before/after the await call
       expect(beforeInstance).toBe(afterInstance)
+      // instance scope should be fully restored/cleaned after async ticks
+      expect((beforeInstance!.scope as any)._on).toBe(0)
     })
 
     test('should not leak instance on multiple awaits', async () => {

+ 21 - 1
packages/runtime-core/src/apiSetupHelpers.ts

@@ -514,11 +514,31 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
   }
   let awaitable = getAwaitable()
   unsetCurrentInstance()
+
+  // Never restore a captured "prev" instance here: in concurrent async setup
+  // continuations it may belong to a sibling component and cause leaks.
+  // We only need to balance ctx.scope.on() from setCurrentInstance(ctx),
+  // then clear global currentInstance for user microtasks.
+  const cleanup = () => {
+    if (getCurrentInstance() !== ctx) ctx.scope.off()
+    unsetCurrentInstance()
+  }
+
   if (isPromise(awaitable)) {
     awaitable = awaitable.catch(e => {
       setCurrentInstance(ctx)
+      // Defer cleanup so the async function's catch continuation
+      // still runs with the restored instance.
+      Promise.resolve().then(() => Promise.resolve().then(cleanup))
       throw e
     })
   }
-  return [awaitable, () => setCurrentInstance(ctx)]
+  return [
+    awaitable,
+    () => {
+      setCurrentInstance(ctx)
+      // Keep instance for the current continuation, then cleanup.
+      Promise.resolve().then(cleanup)
+    },
+  ]
 }