Skip to content

Commit f22e7cd

Browse files
committed
Fix a class of logcontext leaks
So, it turns out that if you have a first `Deferred` `D1`, you can add a callback which returns another `Deferred` `D2`, and `D2` must then complete before any further callbacks on `D1` will execute (and later callbacks on `D1` get the *result* of `D2` rather than `D2` itself). So, `D1` might have `called=True` (as in, it has started running its callbacks), but any new callbacks added to `D1` won't get run until `D2` completes - so if you `yield D1` in an `inlineCallbacks` function, your `yield` will 'block'. In conclusion: some of our assumptions in `logcontext` were invalid. We need to make sure that we don't optimise out the logcontext juggling when this situation happens. Fortunately, it is easy to detect by checking `D1.paused`.
1 parent ca72111 commit f22e7cd

File tree

2 files changed

+94
-33
lines changed

2 files changed

+94
-33
lines changed

synapse/util/logcontext.py

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ def g(*args, **kwargs):
302302
def run_in_background(f, *args, **kwargs):
303303
"""Calls a function, ensuring that the current context is restored after
304304
return from the function, and that the sentinel context is set once the
305-
deferred returned by the funtion completes.
305+
deferred returned by the function completes.
306306
307307
Useful for wrapping functions that return a deferred which you don't yield
308308
on (for instance because you want to pass it to deferred.gatherResults()).
@@ -320,24 +320,31 @@ def run_in_background(f, *args, **kwargs):
320320
# by synchronous exceptions, so let's turn them into Failures.
321321
return defer.fail()
322322

323-
if isinstance(res, defer.Deferred) and not res.called:
324-
# The function will have reset the context before returning, so
325-
# we need to restore it now.
326-
LoggingContext.set_current_context(current)
327-
328-
# The original context will be restored when the deferred
329-
# completes, but there is nothing waiting for it, so it will
330-
# get leaked into the reactor or some other function which
331-
# wasn't expecting it. We therefore need to reset the context
332-
# here.
333-
#
334-
# (If this feels asymmetric, consider it this way: we are
335-
# effectively forking a new thread of execution. We are
336-
# probably currently within a ``with LoggingContext()`` block,
337-
# which is supposed to have a single entry and exit point. But
338-
# by spawning off another deferred, we are effectively
339-
# adding a new exit point.)
340-
res.addBoth(_set_context_cb, LoggingContext.sentinel)
323+
if not isinstance(res, defer.Deferred):
324+
return res
325+
326+
if res.called and not res.paused:
327+
# The function should have maintained the logcontext, so we can
328+
# optimise out the messing about
329+
return res
330+
331+
# The function may have reset the context before returning, so
332+
# we need to restore it now.
333+
ctx = LoggingContext.set_current_context(current)
334+
335+
# The original context will be restored when the deferred
336+
# completes, but there is nothing waiting for it, so it will
337+
# get leaked into the reactor or some other function which
338+
# wasn't expecting it. We therefore need to reset the context
339+
# here.
340+
#
341+
# (If this feels asymmetric, consider it this way: we are
342+
# effectively forking a new thread of execution. We are
343+
# probably currently within a ``with LoggingContext()`` block,
344+
# which is supposed to have a single entry and exit point. But
345+
# by spawning off another deferred, we are effectively
346+
# adding a new exit point.)
347+
res.addBoth(_set_context_cb, ctx)
341348
return res
342349

343350

@@ -354,9 +361,18 @@ def make_deferred_yieldable(deferred):
354361
355362
(This is more-or-less the opposite operation to run_in_background.)
356363
"""
357-
if isinstance(deferred, defer.Deferred) and not deferred.called:
358-
prev_context = LoggingContext.set_current_context(LoggingContext.sentinel)
359-
deferred.addBoth(_set_context_cb, prev_context)
364+
if not isinstance(deferred, defer.Deferred):
365+
return deferred
366+
367+
if deferred.called and not deferred.paused:
368+
# it looks like this deferred is ready to run any callbacks we give it
369+
# immediately. We may as well optimise out the logcontext faffery.
370+
return deferred
371+
372+
# ok, we can't be sure that a yield won't block, so let's reset the
373+
# logcontext, and add a callback to the deferred to restore it.
374+
prev_context = LoggingContext.set_current_context(LoggingContext.sentinel)
375+
deferred.addBoth(_set_context_cb, prev_context)
360376
return deferred
361377

362378

tests/util/test_logcontext.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,28 @@ def competing_callback():
3636
yield sleep(0)
3737
self._check_test_key("one")
3838

39-
def _test_preserve_fn(self, function):
39+
def _test_run_in_background(self, function):
4040
sentinel_context = LoggingContext.current_context()
4141

4242
callback_completed = [False]
4343

44-
@defer.inlineCallbacks
45-
def cb():
44+
def test():
4645
context_one.request = "one"
47-
yield function()
48-
self._check_test_key("one")
46+
d = function()
4947

50-
callback_completed[0] = True
48+
def cb(res):
49+
self._check_test_key("one")
50+
callback_completed[0] = True
51+
return res
52+
d.addCallback(cb)
53+
54+
return d
5155

5256
with LoggingContext() as context_one:
5357
context_one.request = "one"
5458

5559
# fire off function, but don't wait on it.
56-
logcontext.preserve_fn(cb)()
60+
logcontext.run_in_background(test)
5761

5862
self._check_test_key("one")
5963

@@ -80,20 +84,31 @@ def check_logcontext():
8084
# test is done once d2 finishes
8185
return d2
8286

83-
def test_preserve_fn_with_blocking_fn(self):
87+
def test_run_in_background_with_blocking_fn(self):
8488
@defer.inlineCallbacks
8589
def blocking_function():
8690
yield sleep(0)
8791

88-
return self._test_preserve_fn(blocking_function)
92+
return self._test_run_in_background(blocking_function)
8993

90-
def test_preserve_fn_with_non_blocking_fn(self):
94+
def test_run_in_background_with_non_blocking_fn(self):
9195
@defer.inlineCallbacks
9296
def nonblocking_function():
9397
with logcontext.PreserveLoggingContext():
9498
yield defer.succeed(None)
9599

96-
return self._test_preserve_fn(nonblocking_function)
100+
return self._test_run_in_background(nonblocking_function)
101+
102+
@unittest.DEBUG
103+
def test_run_in_background_with_chained_deferred(self):
104+
# a function which returns a deferred which looks like it has been
105+
# called, but is actually paused
106+
def testfunc():
107+
return logcontext.make_deferred_yieldable(
108+
_chained_deferred_function()
109+
)
110+
111+
return self._test_run_in_background(testfunc)
97112

98113
@defer.inlineCallbacks
99114
def test_make_deferred_yieldable(self):
@@ -118,6 +133,22 @@ def blocking_function():
118133
# now it should be restored
119134
self._check_test_key("one")
120135

136+
@defer.inlineCallbacks
137+
def test_make_deferred_yieldable_with_chained_deferreds(self):
138+
sentinel_context = LoggingContext.current_context()
139+
140+
with LoggingContext() as context_one:
141+
context_one.request = "one"
142+
143+
d1 = logcontext.make_deferred_yieldable(_chained_deferred_function())
144+
# make sure that the context was reset by make_deferred_yieldable
145+
self.assertIs(LoggingContext.current_context(), sentinel_context)
146+
147+
yield d1
148+
149+
# now it should be restored
150+
self._check_test_key("one")
151+
121152
@defer.inlineCallbacks
122153
def test_make_deferred_yieldable_on_non_deferred(self):
123154
"""Check that make_deferred_yieldable does the right thing when its
@@ -132,3 +163,17 @@ def test_make_deferred_yieldable_on_non_deferred(self):
132163
r = yield d1
133164
self.assertEqual(r, "bum")
134165
self._check_test_key("one")
166+
167+
168+
# a function which returns a deferred which has been "called", but
169+
# which had a function which returned another incomplete deferred on
170+
# its callback list, so won't yet call any other new callbacks.
171+
def _chained_deferred_function():
172+
d = defer.succeed(None)
173+
174+
def cb(res):
175+
d2 = defer.Deferred()
176+
reactor.callLater(0, d2.callback, res)
177+
return d2
178+
d.addCallback(cb)
179+
return d

0 commit comments

Comments
 (0)