Skip to content

Commit ec5e22e

Browse files
friedmeta-codesync[bot]
authored andcommitted
folly/python/request_context rework Capsule Storage for Memory Leaks
Summary: Ok I wrote a test to insure the use counts were not leaking. But they were `test_multiple_ctx_copies_memory_leak` was ending up with a shared_ptr ref count of the number of tasks we spawned using each context. This was all using shared_ptr::use_count so it wouldn't matter how many python objects were holding the capsule, we were leaking somehow, even with all the tasks/contextvar deleted. I wasn't able to track it down, but I replaced the PyCapsule with the existing Context object. Context is really a PyCapsule with a easier to read API. Reviewed By: itamaro, prakashgayasen Differential Revision: D84285368 fbshipit-source-id: d4ba1f29ce1538d8e256144a649d4fc431bf4831
1 parent d51a0fe commit ec5e22e

File tree

6 files changed

+120
-73
lines changed

6 files changed

+120
-73
lines changed

third-party/folly/src/folly/python/request_context.pxd

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,11 @@ cdef extern from "folly/io/async/Request.h" namespace "folly":
2626
)
2727

2828

29-
cdef extern from "folly/python/request_context_capsule.h" namespace "folly::python":
30-
cdef object RequestContextToPyCapsule(shared_ptr[RequestContext] ptr)
31-
cdef shared_ptr[RequestContext] PyCapsuleToRequestContext(object ptr)
29+
cdef object RequestContextToPyCapsule(shared_ptr[RequestContext] ptr) noexcept
30+
cdef shared_ptr[RequestContext] PyCapsuleToRequestContext(object capsule) noexcept
31+
cdef bint isRequestContextPyCapsule(object capsule) noexcept
3232

33-
cdef class Context:
34-
cdef shared_ptr[RequestContext] _ptr
3533

36-
cpdef Context save() noexcept
37-
cpdef Context get_from_contextvar() noexcept
38-
cdef object get_PyContext(object context) except *
34+
cpdef object save() noexcept
35+
cpdef object get_from_contextvar() noexcept
36+
cdef object get_PyContext(object context = ?) except *

third-party/folly/src/folly/python/request_context.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ from contextlib import contextmanager
1616
from typing import Generator
1717

1818
class Context:
19-
pass
19+
def use_count(self) -> int: ...
2020

2121
@contextmanager
2222
def active() -> Generator[Context, None, None]: ...
2323
def create() -> Context: ...
2424
def set(ctx: Context) -> Context: ...
2525
def save() -> Context: ...
26-
def get_from_contextvar() -> Context | None: ...
26+
def get_from_contextvar() -> Context: ...

third-party/folly/src/folly/python/request_context.pyx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ from contextlib import contextmanager
1818
from libcpp.memory cimport make_shared
1919
from cpython.object cimport PyObject
2020
from cpython.contextvars cimport get_value_no_default as get_value, PyContextVar_Set, PyContextVar_Reset, PyContextVar_New, PyContext_CheckExact
21-
from cpython.pycapsule cimport PyCapsule_CheckExact
2221
from cpython.pystate cimport PyThreadState
2322
from libcpp.utility cimport move
2423

@@ -29,7 +28,7 @@ cdef object _RequestContext = PyContextVar_New("_RequestContext", NULL)
2928
cdef object set_PyContext(shared_ptr[RequestContext] ptr) except *:
3029
return PyContextVar_Set(_RequestContext, RequestContextToPyCapsule(move(ptr)))
3130

32-
cdef object get_PyContext(object context) except *:
31+
cdef object get_PyContext(object context = None) except *:
3332
"""Return the PyCapsule from the ContextVar"""
3433
if context is None:
3534
return get_value(_RequestContext)
@@ -42,33 +41,53 @@ cdef object get_PyContext(object context) except *:
4241

4342
@cython.auto_pickle(False)
4443
cdef class Context:
44+
""" Python Wrapper around shared_ptr[folly::RequestContext] """
45+
# This is opague to other cython files
46+
cdef shared_ptr[RequestContext] _ptr
47+
4548
def __eq__(self, Context other):
4649
return self._ptr.get() == other._ptr.get()
4750

4851
def __hash__(self):
4952
return <unsigned long>(self._ptr.get())
5053

5154
def __repr__(self):
52-
return f"<{self.__class__!r}: {<unsigned long>self._ptr.get()}>"
55+
return f"<Python Capsule shared_ptr<folly::RequestContext>: {<unsigned long>self._ptr.get()}>"
5356

5457
def __bool__(self):
5558
return self._ptr.get() != NULL
5659

60+
def use_count(self):
61+
return self._ptr.use_count()
62+
63+
64+
cdef object RequestContextToPyCapsule(shared_ptr[RequestContext] ptr) noexcept:
65+
cdef Context holder = Context.__new__(Context)
66+
holder._ptr = move(ptr)
67+
return holder
68+
69+
cdef shared_ptr[RequestContext] PyCapsuleToRequestContext(object capsule) noexcept:
70+
cdef Context holder = <Context>capsule
71+
return holder._ptr
5772

58-
cpdef Context save() noexcept:
73+
cdef bint isRequestContextPyCapsule(object capsule) noexcept:
74+
return isinstance(capsule, Context)
75+
76+
77+
cpdef object save() noexcept:
5978
""" Return the current setcontext """
6079
cdef Context ctx = Context.__new__(Context)
61-
ctx._ptr = RequestContext.saveContext()
80+
ctx._ptr = move(RequestContext.saveContext())
6281
return ctx
6382

6483

65-
cpdef Context get_from_contextvar() noexcept:
84+
cpdef object get_from_contextvar() noexcept:
6685
""" Return the current context from the contextvar """
6786
cdef Context ctx = Context.__new__(Context)
6887

6988
ctx_var = get_value(_RequestContext)
70-
if ctx_var is not None and PyCapsule_CheckExact(ctx_var):
71-
ctx._ptr = PyCapsuleToRequestContext(ctx_var)
89+
if isinstance(ctx_var, Context):
90+
return ctx_var
7291
return ctx
7392

7493

@@ -77,12 +96,13 @@ def active():
7796
""" Create a Context and shove it into the python contextvar """
7897
cdef shared_ptr[RequestContext] rctx = make_shared[RequestContext]()
7998
prev_rctx = RequestContext.setContext(rctx)
80-
cdef Context ctx = Context.__new__(Context)
81-
ctx._ptr = rctx
99+
cdef Context ctx = get_from_contextvar()
100+
assert ctx._ptr == rctx
101+
rctx.reset() # So we have correct use_count for tests
82102
try:
83103
yield ctx
84104
finally:
85-
assert RequestContext.setContext(prev_rctx) == rctx
105+
assert RequestContext.setContext(move(prev_rctx)) == ctx._ptr
86106

87107

88108
cdef extern from "folly/python/request_context.h":
@@ -107,11 +127,11 @@ cdef int _watcher(PyContextEvent event, PyObject* pycontext):
107127
return 0
108128

109129
py_ctx = get_value(_RequestContext)
110-
if py_ctx is not None and PyCapsule_CheckExact(py_ctx):
130+
if isinstance(py_ctx, Context):
111131
ctx = PyCapsuleToRequestContext(py_ctx)
112132

113133
# This is always called so we don't leak RC between PyContext switches.
114-
RequestContext.setContext(ctx)
134+
RequestContext.setContext(move(ctx))
115135
return 0
116136

117137

@@ -140,7 +160,7 @@ cdef void _setContextWatcher(const shared_ptr[RequestContext]& prev_ctx, const s
140160
# We don't have a contextvar set and we don't have a context, so we don't need to do anything
141161
return
142162

143-
if py_ctx is not None and PyCapsule_CheckExact(py_ctx):
163+
if isinstance(py_ctx, Context):
144164
if PyCapsuleToRequestContext(py_ctx).get() == curr_ctx.get():
145165
# We triggered this change in our context watcher, so we don't need to do anything
146166
return

third-party/folly/src/folly/python/request_context_capsule.h

Lines changed: 0 additions & 48 deletions
This file was deleted.

third-party/folly/src/folly/python/test/request_context.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import asyncio
1616
import itertools
17+
import random
18+
1719
from contextvars import copy_context
1820
from functools import partial
1921
from unittest import IsolatedAsyncioTestCase
@@ -33,6 +35,79 @@ async def get_Context(pass_ctx) -> request_context.Context | None:
3335

3436

3537
class RequestContextTest(IsolatedAsyncioTestCase):
38+
def test_use_count(self) -> None:
39+
ctx = request_context.Context()
40+
# ctx is a nullptr capsule, its use count is 0
41+
self.assertEqual(ctx.use_count(), 0)
42+
self.assertFalse(ctx)
43+
del ctx
44+
# Set the frc to something.
45+
frc_helper.setContext()
46+
# Fetch that ctx, from the PyContext
47+
ctx = request_context.get_from_contextvar()
48+
# ctx, and folly threadlocal
49+
self.assertEqual(ctx.use_count(), 2)
50+
new_ctx = request_context.save()
51+
self.assertEqual(ctx.use_count(), 3)
52+
del new_ctx
53+
self.assertEqual(ctx.use_count(), 2)
54+
# Set the frc to something else
55+
frc_helper.setContext()
56+
# Just ctx still exists.
57+
self.assertEqual(ctx.use_count(), 1)
58+
59+
async def test_memory_leaks(self) -> None:
60+
with request_context.active() as ctx:
61+
# Owners
62+
# 1. ctx ( also the same as the PyContext capsule )
63+
# 2. folly::RequestContext:: threadlocal
64+
self.assertEqual(ctx.use_count(), 2)
65+
self.assertEqual(await get_Context(ctx), ctx)
66+
self.assertEqual(ctx.use_count(), 2)
67+
# Copy_context shouldn't leak
68+
context = copy_context()
69+
# Still only one capsule.
70+
self.assertEqual(ctx.use_count(), 2)
71+
# Owner 2 goes away because the FRC is not set
72+
self.assertEqual(ctx.use_count(), 1)
73+
del context
74+
self.assertEqual(ctx.use_count(), 1)
75+
76+
async def test_multiple_ctx_copies_memory_leak(self) -> None:
77+
tasks = []
78+
with request_context.active() as ctx1:
79+
for _ in range(20):
80+
tasks.append(asyncio.create_task(get_Context(ctx1)))
81+
82+
with request_context.active() as ctx2:
83+
for _ in range(28):
84+
tasks.append(asyncio.create_task(get_Context(ctx2)))
85+
# Single PyCapsule and ctx object
86+
self.assertEqual(ctx1.use_count(), 1)
87+
self.assertEqual(ctx2.use_count(), 1)
88+
89+
# Now there are 20 PyContext objects
90+
91+
async def test() -> None:
92+
await asyncio.sleep(0)
93+
self.assertEqual(ctx1.use_count(), 1)
94+
await asyncio.sleep(0)
95+
self.assertEqual(ctx2.use_count(), 1)
96+
97+
tasks.append(asyncio.create_task(test()))
98+
99+
random.shuffle(tasks)
100+
101+
await asyncio.gather(*tasks)
102+
103+
self.assertEqual(ctx1.use_count(), 1)
104+
self.assertEqual(ctx2.use_count(), 1)
105+
106+
del tasks
107+
108+
self.assertEqual(ctx1.use_count(), 1)
109+
self.assertEqual(ctx2.use_count(), 1)
110+
36111
async def test_request_context(self) -> None:
37112
with request_context.active() as ctx1:
38113
# Create task used copy_context() during its creation
@@ -44,6 +119,7 @@ async def test_request_context(self) -> None:
44119
request_context.get_from_contextvar(), request_context.save()
45120
)
46121
task1 = asyncio.create_task(get_Context(ctx1))
122+
self.assertEqual(ctx1.use_count(), 2) # New Context but same PyCapsule
47123

48124
with request_context.active() as ctx2:
49125
task2 = asyncio.create_task(get_Context(ctx2))

third-party/folly/src/folly/python/test/request_context_helper.pyx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414

1515
from folly.request_context cimport RequestContext
1616
from libcpp.memory cimport make_shared, shared_ptr
17+
from libcpp.utility cimport move
1718

1819
def setContext():
1920
cdef shared_ptr[RequestContext] ptr = make_shared[RequestContext]()
20-
RequestContext.setContext(ptr)
21+
RequestContext.setContext(move(ptr))

0 commit comments

Comments
 (0)