@@ -249,6 +249,204 @@ async def competing_callback() -> None:
249249 # Back to the sentinel context
250250 self ._check_test_key ("sentinel" )
251251
252+ @logcontext_clean
253+ async def test_deferred_callback_await_in_current_logcontext (self ) -> None :
254+ """
255+ Test that calling the deferred callback in the current logcontext ("foo") and
256+ waiting for it to finish in a logcontext blocks works as expected.
257+
258+ Works because "always await your awaitables".
259+
260+ Demonstrates one pattern that we can use fix the naive case where we just call
261+ `d.callback(None)` without anything else. See the *Deferred callbacks* section
262+ of docs/log_contexts.md for more details.
263+ """
264+ clock = Clock (reactor )
265+
266+ # Sanity check that we start in the sentinel context
267+ self ._check_test_key ("sentinel" )
268+
269+ callback_finished = False
270+
271+ async def competing_callback () -> None :
272+ nonlocal callback_finished
273+ try :
274+ # The deferred callback should have the same logcontext as the caller
275+ self ._check_test_key ("foo" )
276+
277+ with LoggingContext ("competing" ):
278+ await clock .sleep (0 )
279+ self ._check_test_key ("competing" )
280+
281+ self ._check_test_key ("foo" )
282+ finally :
283+ # When exceptions happen, we still want to mark the callback as finished
284+ # so that the test can complete and we see the underlying error.
285+ callback_finished = True
286+
287+ with LoggingContext ("foo" ):
288+ d : defer .Deferred [None ] = defer .Deferred ()
289+ d .addCallback (lambda _ : defer .ensureDeferred (competing_callback ()))
290+ self ._check_test_key ("foo" )
291+ d .callback (None )
292+ # The fix for the naive case is here (i.e. things don't work correctly if we
293+ # don't await here).
294+ #
295+ # Wait for `d` to finish before continuing so the "main" logcontext is
296+ # still active. This works because `d` already follows our logcontext
297+ # rules. If not, we would also have to use `make_deferred_yieldable(d)`.
298+ await d
299+ self ._check_test_key ("foo" )
300+
301+ await clock .sleep (0 )
302+
303+ self .assertTrue (
304+ callback_finished ,
305+ "Callback never finished which means the test probably didn't wait long enough" ,
306+ )
307+
308+ # Back to the sentinel context
309+ self ._check_test_key ("sentinel" )
310+
311+ @logcontext_clean
312+ async def test_deferred_callback_preserve_logging_context (self ) -> None :
313+ """
314+ Test that calling the deferred callback inside `PreserveLoggingContext()` (in
315+ the sentinel context) works as expected.
316+
317+ Demonstrates one pattern that we can use fix the naive case where we just call
318+ `d.callback(None)` without anything else. See the *Deferred callbacks* section
319+ of docs/log_contexts.md for more details.
320+ """
321+ clock = Clock (reactor )
322+
323+ # Sanity check that we start in the sentinel context
324+ self ._check_test_key ("sentinel" )
325+
326+ callback_finished = False
327+
328+ async def competing_callback () -> None :
329+ nonlocal callback_finished
330+ try :
331+ # The deferred callback should have the same logcontext as the caller
332+ self ._check_test_key ("sentinel" )
333+
334+ with LoggingContext ("competing" ):
335+ await clock .sleep (0 )
336+ self ._check_test_key ("competing" )
337+
338+ self ._check_test_key ("sentinel" )
339+ finally :
340+ # When exceptions happen, we still want to mark the callback as finished
341+ # so that the test can complete and we see the underlying error.
342+ callback_finished = True
343+
344+ with LoggingContext ("foo" ):
345+ d : defer .Deferred [None ] = defer .Deferred ()
346+ d .addCallback (lambda _ : defer .ensureDeferred (competing_callback ()))
347+ self ._check_test_key ("foo" )
348+ # The fix for the naive case is here (i.e. things don't work correctly if we
349+ # don't `PreserveLoggingContext()` here).
350+ #
351+ # `PreserveLoggingContext` will reset the logcontext to the sentinel before
352+ # calling the callback, and restore the "foo" logcontext afterwards before
353+ # continuing the foo block. This solves the problem because when the
354+ # "competing" logcontext exits, it will restore the sentinel logcontext
355+ # which is never finished by its nature, so there is no warning and no
356+ # leakage into the reactor.
357+ with PreserveLoggingContext ():
358+ d .callback (None )
359+ self ._check_test_key ("foo" )
360+
361+ await clock .sleep (0 )
362+
363+ self .assertTrue (
364+ callback_finished ,
365+ "Callback never finished which means the test probably didn't wait long enough" ,
366+ )
367+
368+ # Back to the sentinel context
369+ self ._check_test_key ("sentinel" )
370+
371+ @logcontext_clean
372+ async def test_deferred_callback_fire_and_forget_with_current_context (self ) -> None :
373+ """
374+ Test that it's possible to call the deferred callback with the current context
375+ while fire-and-forgetting the callback (no adverse effects like leaking the
376+ logcontext into the reactor or restarting an already finished logcontext).
377+
378+ Demonstrates one pattern that we can use fix the naive case where we just call
379+ `d.callback(None)` without anything else. See the *Deferred callbacks* section
380+ of docs/log_contexts.md for more details.
381+ """
382+ clock = Clock (reactor )
383+
384+ # Sanity check that we start in the sentinel context
385+ self ._check_test_key ("sentinel" )
386+
387+ callback_finished = False
388+
389+ async def competing_callback () -> None :
390+ nonlocal callback_finished
391+ try :
392+ # The deferred callback should have the same logcontext as the caller
393+ self ._check_test_key ("foo" )
394+
395+ with LoggingContext ("competing" ):
396+ await clock .sleep (0 )
397+ self ._check_test_key ("competing" )
398+
399+ self ._check_test_key ("foo" )
400+ finally :
401+ # When exceptions happen, we still want to mark the callback as finished
402+ # so that the test can complete and we see the underlying error.
403+ callback_finished = True
404+
405+ # Part of fix for the naive case is here (i.e. things don't work correctly if we
406+ # don't `PreserveLoggingContext(...)` here).
407+ #
408+ # We can extend the lifetime of the "foo" logcontext is to avoid calling the
409+ # context manager lifetime methods of `LoggingContext` (`__enter__`/`__exit__`).
410+ # And we can still set the current logcontext by using `PreserveLoggingContext`
411+ # and passing in the "foo" logcontext.
412+ with PreserveLoggingContext (LoggingContext ("foo" )):
413+ d : defer .Deferred [None ] = defer .Deferred ()
414+ d .addCallback (lambda _ : defer .ensureDeferred (competing_callback ()))
415+ self ._check_test_key ("foo" )
416+ # Other part of fix for the naive case is here (i.e. things don't work
417+ # correctly if we don't `run_in_background(...)` here).
418+ #
419+ # `run_in_background(...)` will run the whole lambda in the current
420+ # logcontext and it handles the magic behind the scenes of a) restoring the
421+ # calling logcontext before returning to the caller and b) resetting the
422+ # logcontext to the sentinel after the deferred completes and we yield
423+ # control back to the reactor to avoid leaking the logcontext into the
424+ # reactor.
425+ #
426+ # We're using a lambda here as a little trick so we can still get everything
427+ # to run in the "foo" logcontext, but return the deferred `d` itself so that
428+ # `run_in_background` will wait on that to complete before resetting the
429+ # logcontext to the sentinel.
430+ #
431+ # type-ignore[call-overload]: This appears like a mypy type inference bug. A
432+ # function that returns a deferred is exactly what `run_in_background`
433+ # expects.
434+ #
435+ # type-ignore[func-returns-value]: This appears like a mypy type inference
436+ # bug. We're always returning the deferred `d`.
437+ run_in_background (lambda : (d .callback (None ), d )[1 ]) # type: ignore[call-overload, func-returns-value]
438+ self ._check_test_key ("foo" )
439+
440+ await clock .sleep (0 )
441+
442+ self .assertTrue (
443+ callback_finished ,
444+ "Callback never finished which means the test probably didn't wait long enough" ,
445+ )
446+
447+ # Back to the sentinel context
448+ self ._check_test_key ("sentinel" )
449+
252450 async def _test_run_in_background (self , function : Callable [[], object ]) -> None :
253451 clock = Clock (reactor )
254452
0 commit comments