[{"data":1,"prerenderedAt":1657},["ShallowReactive",2],{"page-\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002F":3},{"id":4,"title":5,"body":6,"description":1616,"extension":1617,"meta":1618,"navigation":290,"path":1653,"seo":1654,"stem":1655,"__hash__":1656},"content\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Findex.md","Timeouts, Cancellation & Deadlines",{"type":7,"value":8,"toc":1596},"minimark",[9,13,18,67,71,74,86,92,103,205,209,214,261,270,274,311,314,318,396,400,468,483,487,544,553,557,560,606,613,623,627,755,759,771,782,885,915,919,929,1005,1027,1031,1037,1222,1246,1263,1391,1395,1398,1408,1422,1441,1460,1464,1467,1475,1487,1491,1513,1525,1534,1540,1546,1550,1585,1592],[10,11,12],"p",{},"A hung test is worse than a failing one. A failure names a file, a line and an assertion; a hang produces a job that runs to the platform's limit and is killed with no output, at which point the only available diagnosis is \"something in the suite didn't finish\". Async and threaded suites hang for a small number of well-understood reasons, and every one of them is preventable by layering deadlines so that waiting always has an end.",[14,15,17],"h2",{"id":16},"prerequisites","Prerequisites",[19,20,21,33,51,58],"ul",{},[22,23,24,28,29,32],"li",{},[25,26,27],"code",{},"pytest >= 8.0"," and ",[25,30,31],{},"pytest-timeout >= 2.3",".",[22,34,35,36,28,39,42,43,46,47,50],{},"Python 3.11+ for ",[25,37,38],{},"asyncio.timeout",[25,40,41],{},"asyncio.TaskGroup","; on 3.10 use ",[25,44,45],{},"async_timeout"," or ",[25,48,49],{},"anyio.fail_after",", which behave the same way.",[22,52,53,54,57],{},"A working understanding of how cancellation is delivered in asyncio — as an exception at the next ",[25,55,56],{},"await",", not as a thread kill.",[22,59,60,61,66],{},"The loop-lifetime rules from ",[62,63,65],"a",{"href":64},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002F","pytest-asyncio in depth",", since a mismatched loop is a common cause of a hang that looks like a timeout bug.",[14,68,70],{"id":69},"core-concept-three-layers-three-jobs","Core concept: three layers, three jobs",[10,72,73],{},"Deadlines in a test suite come in three layers and conflating them is the usual mistake.",[10,75,76,77,81,82,85],{},"The ",[78,79,80],"strong",{},"suite ceiling"," is a wall-clock limit applied to every test by ",[25,83,84],{},"pytest-timeout",". Its job is containment: no test may run forever, whatever goes wrong. It is not an assertion and should never be tight enough to fail a merely slow test.",[10,87,76,88,91],{},[78,89,90],{},"per-test override"," raises or lowers the ceiling for a specific test that is legitimately slower or that is deliberately exercising a hang. Its job is to keep the ceiling honest for everyone else.",[10,93,76,94,97,98,102],{},[78,95,96],{},"per-operation deadline"," lives inside the test body, wrapping the call under test. Its job is ",[99,100,101],"em",{},"assertion",": it states that this operation must complete, or must give up, within a bound, and it is the only one of the three that tests the code's own timeout behaviour.",[104,105,108,201],"figure",{"className":106},[107],"diagram",[109,110,117,118,117,122,117,126,117,134,117,144,117,153,117,159,117,165,117,174,117,178,117,183,117,191,117,196],"svg",{"viewBox":111,"role":112,"ariaLabelledBy":113,"xmlns":116},"0 0 820 268","img",[114,115],"layers-t","layers-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[119,120,121],"title",{"id":114},"Three nested deadline layers and what each one catches",[123,124,125],"desc",{"id":115},"An outer band shows the suite ceiling of sixty seconds applied by pytest-timeout, which contains a per-test override of ten seconds, which contains a per-operation deadline of five hundred milliseconds inside the test body. Each band is annotated with what it catches and what kind of report it produces.",[127,128],"rect",{"x":129,"y":129,"width":130,"height":131,"rx":132,"fill":133},"0","820","268","14","#fffdf8",[135,136,143],"text",{"x":137,"y":138,"textAnchor":139,"fontSize":140,"fontWeight":141,"fill":142},"410","28","middle","16","700","#3d405b","Containment outside, assertion inside",[127,145],{"x":146,"y":147,"width":148,"height":149,"rx":132,"fill":150,"stroke":151,"strokeWidth":152},"30","48","760","196","#fbe9e3","#e07a5f","2",[135,154,158],{"x":155,"y":156,"fontSize":157,"fontWeight":141,"fill":142},"50","72","12.5","suite ceiling — pytest-timeout, 60 s",[135,160,164],{"x":155,"y":161,"fontSize":162,"fill":163},"92","11","#8f3d22","catches: any hang at all · produces: a stack dump, job survives",[127,166],{"x":167,"y":168,"width":169,"height":170,"rx":171,"fill":172,"stroke":173,"strokeWidth":152},"62","104","696","128","12","#f7f0da","#f2cc8f",[135,175,177],{"x":176,"y":170,"fontSize":157,"fontWeight":141,"fill":142},"82","per-test override — @pytest.mark.timeout(10)",[135,179,182],{"x":176,"y":180,"fontSize":162,"fill":181},"148","#8a5a00","catches: this test hanging · produces: a normal failure",[127,184],{"x":185,"y":186,"width":187,"height":188,"rx":162,"fill":189,"stroke":190,"strokeWidth":152},"96","160","628","60","#e6f0ea","#81b29a",[135,192,195],{"x":193,"y":194,"fontSize":157,"fontWeight":141,"fill":142},"116","184","per-operation deadline — async with asyncio.timeout(0.5)",[135,197,200],{"x":193,"y":198,"fontSize":162,"fill":199},"204","#2a5f49","catches: the code failing to give up · produces: the assertion you wanted",[202,203,204],"figcaption",{},"Only the innermost layer tests the code. The outer two exist so that when the innermost is missing or wrong, the failure is still legible.",[14,206,208],{"id":207},"step-by-step-implementation","Step-by-step implementation",[210,211,213],"h3",{"id":212},"_1-set-the-suite-ceiling","1. Set the suite ceiling",[215,216,221],"pre",{"className":217,"code":218,"language":219,"meta":220,"style":220},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.pytest.ini_options]\ntimeout = 60\n# \"thread\" dumps every thread's stack before killing the process, which is the\n# only useful output when the hang is a deadlock rather than a slow call.\ntimeout_method = \"thread\"\n","toml","",[25,222,223,231,237,243,249,255],{"__ignoreMap":220},[224,225,228],"span",{"class":226,"line":227},"line",1,[224,229,230],{},"# pyproject.toml\n",[224,232,234],{"class":226,"line":233},2,[224,235,236],{},"[tool.pytest.ini_options]\n",[224,238,240],{"class":226,"line":239},3,[224,241,242],{},"timeout = 60\n",[224,244,246],{"class":226,"line":245},4,[224,247,248],{},"# \"thread\" dumps every thread's stack before killing the process, which is the\n",[224,250,252],{"class":226,"line":251},5,[224,253,254],{},"# only useful output when the hang is a deadlock rather than a slow call.\n",[224,256,258],{"class":226,"line":257},6,[224,259,260],{},"timeout_method = \"thread\"\n",[10,262,263,266,267,32],{},[25,264,265],{},"signal"," is the other method: it raises inside the running test at the exact line, producing an ordinary pytest failure with a real traceback. It is better when it works, and it only works on the main thread of a Unix process, so a suite that uses worker threads or runs on Windows needs ",[25,268,269],{},"thread",[210,271,273],{"id":272},"_2-override-where-a-test-is-genuinely-slower","2. Override where a test is genuinely slower",[215,275,279],{"className":276,"code":277,"language":278,"meta":220,"style":220},"language-python shiki shiki-themes github-light github-dark","import pytest\n\n\n@pytest.mark.timeout(300)              # a migration test against a real database\ndef test_full_migration_applies(engine_migrated):\n    assert engine_migrated.dialect.has_table(engine_migrated.connect(), \"widget\")\n","python",[25,280,281,286,292,296,301,306],{"__ignoreMap":220},[224,282,283],{"class":226,"line":227},[224,284,285],{},"import pytest\n",[224,287,288],{"class":226,"line":233},[224,289,291],{"emptyLinePlaceholder":290},true,"\n",[224,293,294],{"class":226,"line":239},[224,295,291],{"emptyLinePlaceholder":290},[224,297,298],{"class":226,"line":245},[224,299,300],{},"@pytest.mark.timeout(300)              # a migration test against a real database\n",[224,302,303],{"class":226,"line":251},[224,304,305],{},"def test_full_migration_applies(engine_migrated):\n",[224,307,308],{"class":226,"line":257},[224,309,310],{},"    assert engine_migrated.dialect.has_table(engine_migrated.connect(), \"widget\")\n",[10,312,313],{},"Raising the global ceiling to accommodate one slow test costs every other test its containment. The marker keeps the exception local and documented.",[210,315,317],{"id":316},"_3-put-the-real-assertion-inside-the-test","3. Put the real assertion inside the test",[215,319,321],{"className":276,"code":320,"language":278,"meta":220,"style":220},"import asyncio\n\nimport pytest\n\n\nasync def test_client_gives_up_on_a_stalled_server(stalling_server):\n    # This asserts the CLIENT's timeout behaviour. Without it, the test would\n    # pass only because pytest-timeout eventually killed the process.\n    with pytest.raises(TimeoutError):\n        async with asyncio.timeout(0.5):\n            await stalling_server.client.fetch(\"\u002Fnever-responds\")\n\n    # And the cleanup path must have run.\n    assert stalling_server.client.open_connections == 0\n",[25,322,323,328,332,336,340,344,349,355,361,367,373,379,384,390],{"__ignoreMap":220},[224,324,325],{"class":226,"line":227},[224,326,327],{},"import asyncio\n",[224,329,330],{"class":226,"line":233},[224,331,291],{"emptyLinePlaceholder":290},[224,333,334],{"class":226,"line":239},[224,335,285],{},[224,337,338],{"class":226,"line":245},[224,339,291],{"emptyLinePlaceholder":290},[224,341,342],{"class":226,"line":251},[224,343,291],{"emptyLinePlaceholder":290},[224,345,346],{"class":226,"line":257},[224,347,348],{},"async def test_client_gives_up_on_a_stalled_server(stalling_server):\n",[224,350,352],{"class":226,"line":351},7,[224,353,354],{},"    # This asserts the CLIENT's timeout behaviour. Without it, the test would\n",[224,356,358],{"class":226,"line":357},8,[224,359,360],{},"    # pass only because pytest-timeout eventually killed the process.\n",[224,362,364],{"class":226,"line":363},9,[224,365,366],{},"    with pytest.raises(TimeoutError):\n",[224,368,370],{"class":226,"line":369},10,[224,371,372],{},"        async with asyncio.timeout(0.5):\n",[224,374,376],{"class":226,"line":375},11,[224,377,378],{},"            await stalling_server.client.fetch(\"\u002Fnever-responds\")\n",[224,380,382],{"class":226,"line":381},12,[224,383,291],{"emptyLinePlaceholder":290},[224,385,387],{"class":226,"line":386},13,[224,388,389],{},"    # And the cleanup path must have run.\n",[224,391,393],{"class":226,"line":392},14,[224,394,395],{},"    assert stalling_server.client.open_connections == 0\n",[210,397,399],{"id":398},"_4-test-the-cancellation-path-explicitly","4. Test the cancellation path explicitly",[215,401,403],{"className":276,"code":402,"language":278,"meta":220,"style":220},"import asyncio\n\nimport pytest\n\n\nasync def test_cancellation_releases_the_lock(worker, lock):\n    task = asyncio.create_task(worker.run_forever())\n    await worker.started.wait()        # deterministic: wait for the signal, not a sleep\n\n    task.cancel()\n    with pytest.raises(asyncio.CancelledError):\n        await task                     # awaiting the cancelled task re-raises\n\n    assert not lock.locked(), \"the worker's finally block did not release the lock\"\n",[25,404,405,409,413,417,421,425,430,435,440,444,449,454,459,463],{"__ignoreMap":220},[224,406,407],{"class":226,"line":227},[224,408,327],{},[224,410,411],{"class":226,"line":233},[224,412,291],{"emptyLinePlaceholder":290},[224,414,415],{"class":226,"line":239},[224,416,285],{},[224,418,419],{"class":226,"line":245},[224,420,291],{"emptyLinePlaceholder":290},[224,422,423],{"class":226,"line":251},[224,424,291],{"emptyLinePlaceholder":290},[224,426,427],{"class":226,"line":257},[224,428,429],{},"async def test_cancellation_releases_the_lock(worker, lock):\n",[224,431,432],{"class":226,"line":351},[224,433,434],{},"    task = asyncio.create_task(worker.run_forever())\n",[224,436,437],{"class":226,"line":357},[224,438,439],{},"    await worker.started.wait()        # deterministic: wait for the signal, not a sleep\n",[224,441,442],{"class":226,"line":363},[224,443,291],{"emptyLinePlaceholder":290},[224,445,446],{"class":226,"line":369},[224,447,448],{},"    task.cancel()\n",[224,450,451],{"class":226,"line":375},[224,452,453],{},"    with pytest.raises(asyncio.CancelledError):\n",[224,455,456],{"class":226,"line":381},[224,457,458],{},"        await task                     # awaiting the cancelled task re-raises\n",[224,460,461],{"class":226,"line":386},[224,462,291],{"emptyLinePlaceholder":290},[224,464,465],{"class":226,"line":392},[224,466,467],{},"    assert not lock.locked(), \"the worker's finally block did not release the lock\"\n",[10,469,470,471,474,475,478,479,482],{},"The last assertion is the test. ",[25,472,473],{},"task.cancel()"," raising ",[25,476,477],{},"CancelledError"," proves only that cancellation was delivered; whether the coroutine's ",[25,480,481],{},"finally"," ran correctly is a separate fact, and it is the one that matters in production.",[210,484,486],{"id":485},"_5-shield-only-what-must-complete","5. Shield only what must complete",[215,488,490],{"className":276,"code":489,"language":278,"meta":220,"style":220},"import asyncio\n\n\nasync def flush_and_close(buffer, connection):\n    try:\n        await stream_forever(buffer, connection)\n    finally:\n        # The flush must finish even if we are being cancelled — but it gets its\n        # own bound, so a stuck flush cannot outlive the cancellation forever.\n        async with asyncio.timeout(2.0):\n            await asyncio.shield(buffer.flush(connection))\n",[25,491,492,496,500,504,509,514,519,524,529,534,539],{"__ignoreMap":220},[224,493,494],{"class":226,"line":227},[224,495,327],{},[224,497,498],{"class":226,"line":233},[224,499,291],{"emptyLinePlaceholder":290},[224,501,502],{"class":226,"line":239},[224,503,291],{"emptyLinePlaceholder":290},[224,505,506],{"class":226,"line":245},[224,507,508],{},"async def flush_and_close(buffer, connection):\n",[224,510,511],{"class":226,"line":251},[224,512,513],{},"    try:\n",[224,515,516],{"class":226,"line":257},[224,517,518],{},"        await stream_forever(buffer, connection)\n",[224,520,521],{"class":226,"line":351},[224,522,523],{},"    finally:\n",[224,525,526],{"class":226,"line":357},[224,527,528],{},"        # The flush must finish even if we are being cancelled — but it gets its\n",[224,530,531],{"class":226,"line":363},[224,532,533],{},"        # own bound, so a stuck flush cannot outlive the cancellation forever.\n",[224,535,536],{"class":226,"line":369},[224,537,538],{},"        async with asyncio.timeout(2.0):\n",[224,540,541],{"class":226,"line":375},[224,542,543],{},"            await asyncio.shield(buffer.flush(connection))\n",[10,545,546,547,549,550,552],{},"An unshielded ",[25,548,56],{}," inside a ",[25,551,481],{}," is itself cancelled immediately when the outer cancellation is already in flight, so the flush never happens. A shield without a bound is the opposite failure: cancellation can no longer stop it. Both together is the correct shape, and it is worth a test of its own.",[14,554,556],{"id":555},"verification","Verification",[10,558,559],{},"Prove the ceiling works by writing a test that deliberately hangs, running it once, and then deleting it — or keeping it behind a marker that only runs on demand:",[215,561,563],{"className":276,"code":562,"language":278,"meta":220,"style":220},"import time\n\nimport pytest\n\n\n@pytest.mark.skip(reason=\"run manually to verify the timeout configuration\")\n@pytest.mark.timeout(2)\ndef test_timeout_configuration_is_live():\n    time.sleep(30)\n",[25,564,565,570,574,578,582,586,591,596,601],{"__ignoreMap":220},[224,566,567],{"class":226,"line":227},[224,568,569],{},"import time\n",[224,571,572],{"class":226,"line":233},[224,573,291],{"emptyLinePlaceholder":290},[224,575,576],{"class":226,"line":239},[224,577,285],{},[224,579,580],{"class":226,"line":245},[224,581,291],{"emptyLinePlaceholder":290},[224,583,584],{"class":226,"line":251},[224,585,291],{"emptyLinePlaceholder":290},[224,587,588],{"class":226,"line":257},[224,589,590],{},"@pytest.mark.skip(reason=\"run manually to verify the timeout configuration\")\n",[224,592,593],{"class":226,"line":351},[224,594,595],{},"@pytest.mark.timeout(2)\n",[224,597,598],{"class":226,"line":357},[224,599,600],{},"def test_timeout_configuration_is_live():\n",[224,602,603],{"class":226,"line":363},[224,604,605],{},"    time.sleep(30)\n",[215,607,611],{"className":608,"code":610,"language":135,"meta":220},[609],"language-text","$ pytest -q -m \"\" --no-skip tests\u002Ftest_meta.py\n+++++++++++++++++++++ Timeout +++++++++++++++++++++\n~~~~~~~~ Stack of MainThread (140234...) ~~~~~~~~\n  File \"tests\u002Ftest_meta.py\", line 9, in test_timeout_configuration_is_live\n    time.sleep(30)\n+++++++++++++++++++ Timeout ++++++++++++++++++++++\n",[25,612,610],{"__ignoreMap":220},[10,614,615,616,619,620,622],{},"Seeing the stack dump once is worth more than assuming the configuration is correct. Teams routinely discover at this point that ",[25,617,618],{},"timeout_method"," was defaulting to ",[25,621,265],{}," inside a threaded suite, where it silently does nothing.",[14,624,626],{"id":625},"troubleshooting","Troubleshooting",[628,629,630,646],"table",{},[631,632,633],"thead",{},[634,635,636,640,643],"tr",{},[637,638,639],"th",{},"Symptom",[637,641,642],{},"Root cause",[637,644,645],{},"Fix",[647,648,649,666,680,703,723,734],"tbody",{},[634,650,651,655,660],{},[652,653,654],"td",{},"Timeout never fires",[652,656,657,659],{},[25,658,265],{}," method inside a worker thread",[652,661,662,663],{},"Set ",[25,664,665],{},"timeout_method = \"thread\"",[634,667,668,671,674],{},[652,669,670],{},"Coroutine keeps running after the deadline",[652,672,673],{},"No await point reached; blocked in sync code",[652,675,676,677],{},"Move blocking work to ",[25,678,679],{},"asyncio.to_thread",[634,681,682,687,697],{},[652,683,684,686],{},[25,685,477],{}," swallowed, task never stops",[652,688,689,692,693,696],{},[25,690,691],{},"except BaseException"," or a bare ",[25,694,695],{},"except:"," in the coroutine",[652,698,699,700,702],{},"Re-raise ",[25,701,477],{}," after cleanup",[634,704,705,708,716],{},[652,706,707],{},"Cleanup skipped on timeout",[652,709,710,712,713,715],{},[25,711,56],{}," in ",[25,714,481],{}," cancelled immediately",[652,717,718,719,722],{},"Wrap in ",[25,720,721],{},"asyncio.shield"," with its own deadline",[634,724,725,728,731],{},[652,726,727],{},"Timeout fires on a healthy CI runner",[652,729,730],{},"Bound set near the average, not the worst case",[652,732,733],{},"Raise it an order of magnitude; it is a net, not an assertion",[634,735,736,746,749],{},[652,737,738,741,742,745],{},[25,739,740],{},"TimeoutError"," vs ",[25,743,744],{},"asyncio.TimeoutError"," confusion",[652,747,748],{},"They are the same class from 3.11",[652,750,751,752,754],{},"Catch ",[25,753,740],{},"; drop the alias",[14,756,758],{"id":757},"why-a-coroutine-can-ignore-a-deadline","Why a coroutine can ignore a deadline",[10,760,761,763,764,766,767,770],{},[25,762,38],{}," does not stop code. It schedules a cancellation on the task, and that cancellation is delivered as a ",[25,765,477],{}," raised ",[99,768,769],{},"at the next suspension point",". A coroutine with no suspension point available cannot receive it.",[10,772,773,774,777,778,781],{},"That is exactly what happens when a coroutine calls blocking code: ",[25,775,776],{},"time.sleep(30)",", a synchronous ",[25,779,780],{},"requests.get",", a CPU-bound loop, a C extension that does not release the GIL. The loop itself is blocked, so it cannot even run the callback that would deliver the cancellation. The deadline expires, nothing happens, and the suite ceiling eventually kills the process — with a stack that correctly points at the blocking call.",[104,783,785,882],{"className":784},[107],[109,786,117,791,117,794,117,797,117,800,117,803,117,808,117,815,117,823,117,829,117,835,117,839,117,843,117,847,117,850,117,854,117,858,117,861,117,864,117,869,117,873,117,877,117,879],{"viewBox":787,"role":112,"ariaLabelledBy":788,"xmlns":116},"0 0 820 264",[789,790],"cancel-t","cancel-d",[119,792,793],{"id":789},"Cancellation delivery depends on reaching an await",[123,795,796],{"id":790},"Two timelines. In the cooperative case the coroutine awaits, the deadline fires, a CancelledError is raised at that await and the finally block runs. In the blocking case the coroutine enters synchronous code, the deadline fires but cannot be delivered, and nothing happens until the suite ceiling kills the process.",[127,798],{"x":129,"y":129,"width":130,"height":799,"rx":132,"fill":133},"264",[135,801,802],{"x":137,"y":138,"textAnchor":139,"fontSize":140,"fontWeight":141,"fill":142},"A deadline is a request, not an interrupt",[135,804,807],{"x":805,"y":806,"fontSize":171,"fontWeight":141,"fill":199},"34","74","has await points",[226,809],{"x1":810,"y1":811,"x2":812,"y2":811,"stroke":813,"strokeWidth":814},"180","70","780","rgba(61,64,91,0.35)","1.3",[127,816],{"x":817,"y":818,"width":819,"height":820,"rx":821,"fill":189,"stroke":190,"strokeWidth":822},"190","54","150","32","7","1.7",[135,824,828],{"x":825,"y":826,"textAnchor":139,"fontSize":827,"fill":142},"265","75","10.5","await recv()",[226,830],{"x1":831,"y1":832,"x2":831,"y2":833,"stroke":173,"strokeWidth":834},"420","44","120","3",[135,836,838],{"x":831,"y":837,"textAnchor":139,"fontSize":827,"fontWeight":141,"fill":181},"38","deadline",[127,840],{"x":841,"y":818,"width":842,"height":820,"rx":821,"fill":150,"stroke":151,"strokeWidth":822},"430","170",[135,844,846],{"x":845,"y":826,"textAnchor":139,"fontSize":827,"fill":142},"515","CancelledError raised",[127,848],{"x":849,"y":818,"width":186,"height":820,"rx":821,"fill":189,"stroke":190,"strokeWidth":822},"612",[135,851,853],{"x":852,"y":826,"textAnchor":139,"fontSize":827,"fill":142},"692","finally runs · test fails",[135,855,857],{"x":805,"y":856,"fontSize":171,"fontWeight":141,"fill":163},"176","blocks the loop",[226,859],{"x1":810,"y1":860,"x2":812,"y2":860,"stroke":813,"strokeWidth":814},"172",[127,862],{"x":817,"y":863,"width":137,"height":820,"rx":821,"fill":150,"stroke":151,"strokeWidth":822},"156",[135,865,868],{"x":866,"y":867,"textAnchor":139,"fontSize":827,"fill":142},"395","177","time.sleep(30) — no suspension point",[226,870],{"x1":831,"y1":871,"x2":831,"y2":872,"stroke":173,"strokeWidth":834},"146","222",[135,874,876],{"x":831,"y":875,"textAnchor":139,"fontSize":827,"fontWeight":141,"fill":181},"240","deadline fires, undeliverable",[127,878],{"x":849,"y":863,"width":186,"height":820,"rx":821,"fill":172,"stroke":173,"strokeWidth":822},[135,880,881],{"x":852,"y":867,"textAnchor":139,"fontSize":827,"fill":142},"suite ceiling kills it",[202,883,884],{},"The lower timeline is why the suite ceiling exists. No amount of per-operation deadline discipline helps when the loop itself cannot run.",[10,886,887,888,891,892,894,895,898,899,901,902,904,905,907,908,910,911,914],{},"A related failure is the coroutine that receives the cancellation and refuses it. ",[25,889,890],{},"except Exception"," does not catch ",[25,893,477],{}," in Python 3.8+, because it inherits from ",[25,896,897],{},"BaseException"," — but ",[25,900,691],{},", a bare ",[25,903,695],{},", or a ",[25,906,481],{}," that awaits something slow all give the coroutine an opportunity to keep running. Code that catches ",[25,909,477],{}," to do cleanup must re-raise it; anything else silently converts a cancellation into a delay, and the test that would have caught it is the one asserting the task ends up in the ",[25,912,913],{},"cancelled"," state.",[14,916,918],{"id":917},"replacing-waits-with-conditions","Replacing waits with conditions",[10,920,921,922,28,925,928],{},"Every ",[25,923,924],{},"time.sleep",[25,926,927],{},"await asyncio.sleep(0.5)"," in a test is a deadline in disguise — one with no diagnosis when it is too short and no speed when it is too long. The replacement is always a condition plus a bound.",[215,930,932],{"className":276,"code":931,"language":278,"meta":220,"style":220},"import asyncio\n\n\nasync def wait_until(predicate, *, timeout=2.0, interval=0.01):\n    \"\"\"Poll until predicate() is true, or fail with a useful message.\"\"\"\n    async with asyncio.timeout(timeout):\n        while not predicate():\n            await asyncio.sleep(interval)    # yields; never a fixed total wait\n\n\nasync def test_worker_drains_the_queue(worker, queue):\n    await queue.put({\"id\": 1})\n    # Returns as soon as the condition holds — typically in one interval.\n    await wait_until(lambda: queue.empty())\n    assert worker.processed == [{\"id\": 1}]\n",[25,933,934,938,942,946,951,956,961,966,971,975,979,984,989,994,999],{"__ignoreMap":220},[224,935,936],{"class":226,"line":227},[224,937,327],{},[224,939,940],{"class":226,"line":233},[224,941,291],{"emptyLinePlaceholder":290},[224,943,944],{"class":226,"line":239},[224,945,291],{"emptyLinePlaceholder":290},[224,947,948],{"class":226,"line":245},[224,949,950],{},"async def wait_until(predicate, *, timeout=2.0, interval=0.01):\n",[224,952,953],{"class":226,"line":251},[224,954,955],{},"    \"\"\"Poll until predicate() is true, or fail with a useful message.\"\"\"\n",[224,957,958],{"class":226,"line":257},[224,959,960],{},"    async with asyncio.timeout(timeout):\n",[224,962,963],{"class":226,"line":351},[224,964,965],{},"        while not predicate():\n",[224,967,968],{"class":226,"line":357},[224,969,970],{},"            await asyncio.sleep(interval)    # yields; never a fixed total wait\n",[224,972,973],{"class":226,"line":363},[224,974,291],{"emptyLinePlaceholder":290},[224,976,977],{"class":226,"line":369},[224,978,291],{"emptyLinePlaceholder":290},[224,980,981],{"class":226,"line":375},[224,982,983],{},"async def test_worker_drains_the_queue(worker, queue):\n",[224,985,986],{"class":226,"line":381},[224,987,988],{},"    await queue.put({\"id\": 1})\n",[224,990,991],{"class":226,"line":386},[224,992,993],{},"    # Returns as soon as the condition holds — typically in one interval.\n",[224,995,996],{"class":226,"line":392},[224,997,998],{},"    await wait_until(lambda: queue.empty())\n",[224,1000,1002],{"class":226,"line":1001},15,[224,1003,1004],{},"    assert worker.processed == [{\"id\": 1}]\n",[10,1006,1007,1008,1011,1012,1014,1015,1018,1019,1022,1023,32],{},"This runs in roughly one polling interval when things are healthy and fails in ",[25,1009,1010],{},"timeout"," seconds with a ",[25,1013,740],{}," when they are not — versus a fixed ",[25,1016,1017],{},"sleep(2)",", which always costs two seconds and, when the work takes 2.1 seconds on a loaded runner, produces a failure that looks like a bug in the worker. Better still is an explicit signal from the code under test: an ",[25,1020,1021],{},"asyncio.Event"," the worker sets, which removes polling entirely. The trade-off between the two, and the cases where polling is the only option, is the subject of ",[62,1024,1026],{"href":1025},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Freplacing-sleep-based-waits-with-polling-assertions\u002F","replacing sleep-based waits with polling assertions",[14,1028,1030],{"id":1029},"deadlines-for-threaded-and-blocking-code","Deadlines for threaded and blocking code",[10,1032,1033,1034,1036],{},"Nothing in asyncio helps a synchronous test that hangs on a lock, a socket read or a subprocess. The standard library primitives all accept a ",[25,1035,1010],{},", and the discipline is simply to pass one every time.",[215,1038,1040],{"className":276,"code":1039,"language":278,"meta":220,"style":220},"import queue\nimport subprocess\nimport threading\n\nimport pytest\n\n\ndef test_worker_publishes_within_the_budget(worker, results: queue.Queue):\n    worker.start()\n    try:\n        # queue.get() without a timeout is an unbounded wait; with one it is an\n        # assertion that the worker produced something in time.\n        item = results.get(timeout=5)\n    except queue.Empty:\n        pytest.fail(\"worker produced nothing within 5 seconds\")\n    finally:\n        worker.stop()\n        worker.join(timeout=5)\n        assert not worker.is_alive(), \"worker ignored stop()\"\n\n    assert item[\"status\"] == \"done\"\n\n\ndef test_cli_exits_rather_than_waiting_for_input():\n    # A subprocess with no timeout is the most common source of a hung suite,\n    # because the child inherits stdin and blocks on a prompt nobody sees.\n    completed = subprocess.run(\n        [\"python\", \"-m\", \"myapp.cli\", \"--check\"],\n        capture_output=True,\n        timeout=30,\n        stdin=subprocess.DEVNULL,   # a closed stdin turns a prompt into an error\n        text=True,\n    )\n    assert completed.returncode == 0, completed.stderr\n",[25,1041,1042,1047,1052,1057,1061,1065,1069,1073,1078,1083,1087,1092,1097,1102,1107,1112,1117,1123,1129,1135,1140,1146,1151,1156,1162,1168,1174,1180,1186,1192,1198,1204,1210,1216],{"__ignoreMap":220},[224,1043,1044],{"class":226,"line":227},[224,1045,1046],{},"import queue\n",[224,1048,1049],{"class":226,"line":233},[224,1050,1051],{},"import subprocess\n",[224,1053,1054],{"class":226,"line":239},[224,1055,1056],{},"import threading\n",[224,1058,1059],{"class":226,"line":245},[224,1060,291],{"emptyLinePlaceholder":290},[224,1062,1063],{"class":226,"line":251},[224,1064,285],{},[224,1066,1067],{"class":226,"line":257},[224,1068,291],{"emptyLinePlaceholder":290},[224,1070,1071],{"class":226,"line":351},[224,1072,291],{"emptyLinePlaceholder":290},[224,1074,1075],{"class":226,"line":357},[224,1076,1077],{},"def test_worker_publishes_within_the_budget(worker, results: queue.Queue):\n",[224,1079,1080],{"class":226,"line":363},[224,1081,1082],{},"    worker.start()\n",[224,1084,1085],{"class":226,"line":369},[224,1086,513],{},[224,1088,1089],{"class":226,"line":375},[224,1090,1091],{},"        # queue.get() without a timeout is an unbounded wait; with one it is an\n",[224,1093,1094],{"class":226,"line":381},[224,1095,1096],{},"        # assertion that the worker produced something in time.\n",[224,1098,1099],{"class":226,"line":386},[224,1100,1101],{},"        item = results.get(timeout=5)\n",[224,1103,1104],{"class":226,"line":392},[224,1105,1106],{},"    except queue.Empty:\n",[224,1108,1109],{"class":226,"line":1001},[224,1110,1111],{},"        pytest.fail(\"worker produced nothing within 5 seconds\")\n",[224,1113,1115],{"class":226,"line":1114},16,[224,1116,523],{},[224,1118,1120],{"class":226,"line":1119},17,[224,1121,1122],{},"        worker.stop()\n",[224,1124,1126],{"class":226,"line":1125},18,[224,1127,1128],{},"        worker.join(timeout=5)\n",[224,1130,1132],{"class":226,"line":1131},19,[224,1133,1134],{},"        assert not worker.is_alive(), \"worker ignored stop()\"\n",[224,1136,1138],{"class":226,"line":1137},20,[224,1139,291],{"emptyLinePlaceholder":290},[224,1141,1143],{"class":226,"line":1142},21,[224,1144,1145],{},"    assert item[\"status\"] == \"done\"\n",[224,1147,1149],{"class":226,"line":1148},22,[224,1150,291],{"emptyLinePlaceholder":290},[224,1152,1154],{"class":226,"line":1153},23,[224,1155,291],{"emptyLinePlaceholder":290},[224,1157,1159],{"class":226,"line":1158},24,[224,1160,1161],{},"def test_cli_exits_rather_than_waiting_for_input():\n",[224,1163,1165],{"class":226,"line":1164},25,[224,1166,1167],{},"    # A subprocess with no timeout is the most common source of a hung suite,\n",[224,1169,1171],{"class":226,"line":1170},26,[224,1172,1173],{},"    # because the child inherits stdin and blocks on a prompt nobody sees.\n",[224,1175,1177],{"class":226,"line":1176},27,[224,1178,1179],{},"    completed = subprocess.run(\n",[224,1181,1183],{"class":226,"line":1182},28,[224,1184,1185],{},"        [\"python\", \"-m\", \"myapp.cli\", \"--check\"],\n",[224,1187,1189],{"class":226,"line":1188},29,[224,1190,1191],{},"        capture_output=True,\n",[224,1193,1195],{"class":226,"line":1194},30,[224,1196,1197],{},"        timeout=30,\n",[224,1199,1201],{"class":226,"line":1200},31,[224,1202,1203],{},"        stdin=subprocess.DEVNULL,   # a closed stdin turns a prompt into an error\n",[224,1205,1207],{"class":226,"line":1206},32,[224,1208,1209],{},"        text=True,\n",[224,1211,1213],{"class":226,"line":1212},33,[224,1214,1215],{},"    )\n",[224,1217,1219],{"class":226,"line":1218},34,[224,1220,1221],{},"    assert completed.returncode == 0, completed.stderr\n",[10,1223,1224,1225,1228,1229,1228,1232,28,1235,1238,1239,1242,1243,1245],{},"Four standard-library calls account for most synchronous hangs: ",[25,1226,1227],{},"Queue.get",", ",[25,1230,1231],{},"Lock.acquire",[25,1233,1234],{},"Thread.join",[25,1236,1237],{},"subprocess.run","\u002F",[25,1240,1241],{},"communicate",". All four default to waiting forever, and all four take a ",[25,1244,1010],{}," argument. A lint rule or a review habit that rejects any of them without one removes an entire category of unreproducible CI failures.",[10,1247,1248,1251,1252,1255,1256,1259,1260,32],{},[25,1249,1250],{},"subprocess"," deserves the extra note above. A child process that reads from stdin will block indefinitely when stdin is an interactive terminal locally and an inherited pipe in CI — the classic \"works on my machine, hangs in the pipeline\" case. Passing ",[25,1253,1254],{},"stdin=subprocess.DEVNULL"," converts the wait into an immediate ",[25,1257,1258],{},"EOFError"," inside the child, which surfaces as a normal non-zero exit with a traceback in ",[25,1261,1262],{},"stderr",[104,1264,1266,1388],{"className":1265},[107],[109,1267,117,1272,117,1275,117,1278,117,1282,117,1286,117,1290,117,1292,117,1296,117,1300,117,1304,117,1307,117,1310,117,1313,117,1315,117,1319,117,1323,117,1326,117,1329,117,1332,117,1335,117,1337,117,1341,117,1345,117,1348,117,1351,117,1354,117,1357,117,1359,117,1363,117,1367,117,1370,117,1373,117,1376,117,1384],{"viewBox":1268,"role":112,"ariaLabelledBy":1269,"xmlns":116},"0 0 800 250",[1270,1271],"hang-t","hang-d",[119,1273,1274],{"id":1270},"The four standard-library calls that wait forever by default",[123,1276,1277],{"id":1271},"Four cards name Queue.get, Lock.acquire, Thread.join and subprocess.run, each with the symptom it produces when it blocks and the argument that bounds it. A footer notes that every one of them accepts a timeout argument that defaults to waiting indefinitely.",[127,1279],{"x":129,"y":129,"width":1280,"height":1281,"rx":132,"fill":133},"800","250",[135,1283,1285],{"x":1284,"y":138,"textAnchor":139,"fontSize":140,"fontWeight":141,"fill":142},"400","Unbounded waits hiding in the standard library",[127,1287],{"x":1288,"y":155,"width":810,"height":1289,"rx":162,"fill":133,"stroke":151,"strokeWidth":152},"24","136",[127,1291],{"x":1288,"y":155,"width":810,"height":146,"rx":162,"fill":142},[135,1293,1295],{"x":1294,"y":811,"textAnchor":139,"fontSize":171,"fontWeight":141,"fill":133},"114","Queue.get()",[135,1297,1299],{"x":837,"y":1298,"fontSize":162,"fill":142},"102","waits for a producer",[135,1301,1303],{"x":837,"y":1302,"fontSize":162,"fill":142},"124","that already died",[135,1305,1306],{"x":837,"y":819,"fontSize":162,"fontWeight":141,"fill":163},"get(timeout=5)",[135,1308,1309],{"x":837,"y":860,"fontSize":162,"fill":142},"then catch Empty",[127,1311],{"x":1312,"y":155,"width":810,"height":1289,"rx":162,"fill":133,"stroke":151,"strokeWidth":152},"216",[127,1314],{"x":1312,"y":155,"width":810,"height":146,"rx":162,"fill":142},[135,1316,1318],{"x":1317,"y":811,"textAnchor":139,"fontSize":171,"fontWeight":141,"fill":133},"306","Lock.acquire()",[135,1320,1322],{"x":1321,"y":1298,"fontSize":162,"fill":142},"230","waits on a holder",[135,1324,1325],{"x":1321,"y":1302,"fontSize":162,"fill":142},"that never releases",[135,1327,1328],{"x":1321,"y":819,"fontSize":162,"fontWeight":141,"fill":163},"acquire(timeout=5)",[135,1330,1331],{"x":1321,"y":860,"fontSize":162,"fill":142},"returns False",[127,1333],{"x":1334,"y":155,"width":810,"height":1289,"rx":162,"fill":133,"stroke":151,"strokeWidth":152},"408",[127,1336],{"x":1334,"y":155,"width":810,"height":146,"rx":162,"fill":142},[135,1338,1340],{"x":1339,"y":811,"textAnchor":139,"fontSize":171,"fontWeight":141,"fill":133},"498","Thread.join()",[135,1342,1344],{"x":1343,"y":1298,"fontSize":162,"fill":142},"422","waits on a worker",[135,1346,1347],{"x":1343,"y":1302,"fontSize":162,"fill":142},"stuck in its own wait",[135,1349,1350],{"x":1343,"y":819,"fontSize":162,"fontWeight":141,"fill":163},"join(timeout=5)",[135,1352,1353],{"x":1343,"y":860,"fontSize":162,"fill":142},"then check is_alive()",[127,1355],{"x":1356,"y":155,"width":856,"height":1289,"rx":162,"fill":133,"stroke":151,"strokeWidth":152},"600",[127,1358],{"x":1356,"y":155,"width":856,"height":146,"rx":162,"fill":142},[135,1360,1362],{"x":1361,"y":811,"textAnchor":139,"fontSize":171,"fontWeight":141,"fill":133},"688","subprocess.run()",[135,1364,1366],{"x":1365,"y":1298,"fontSize":162,"fill":142},"614","child blocks reading",[135,1368,1369],{"x":1365,"y":1302,"fontSize":162,"fill":142},"an inherited stdin",[135,1371,1372],{"x":1365,"y":819,"fontSize":162,"fontWeight":141,"fill":163},"timeout=30",[135,1374,1375],{"x":1365,"y":860,"fontSize":162,"fill":142},"stdin=DEVNULL",[127,1377],{"x":1288,"y":1378,"width":1379,"height":1380,"rx":1381,"fill":1382,"stroke":813,"strokeWidth":1383},"200","752","36","9","#f4f1de","1.5",[135,1385,1387],{"x":1284,"y":1386,"textAnchor":139,"fontSize":171,"fill":142},"223","Every one of these defaults to waiting indefinitely. Passing a bound is the whole fix.",[202,1389,1390],{},"None of these produce a diagnosis when they hang; all four produce a precise one when bounded. The default is the bug.",[14,1392,1394],{"id":1393},"making-a-hang-reportable","Making a hang reportable",[10,1396,1397],{},"When a hang does reach CI despite the layers above, the run should still yield enough information to diagnose it. Two configuration lines cover most of the gap.",[10,1399,1400,1403,1404,1407],{},[25,1401,1402],{},"faulthandler"," is enabled by pytest automatically, and ",[25,1405,1406],{},"faulthandler_timeout"," tells it to dump every thread's stack after a given number of seconds without finishing a test:",[215,1409,1411],{"className":217,"code":1410,"language":219,"meta":220,"style":220},"[tool.pytest.ini_options]\nfaulthandler_timeout = 45     # below the suite ceiling, so the dump lands first\n",[25,1412,1413,1417],{"__ignoreMap":220},[224,1414,1415],{"class":226,"line":227},[224,1416,236],{},[224,1418,1419],{"class":226,"line":233},[224,1420,1421],{},"faulthandler_timeout = 45     # below the suite ceiling, so the dump lands first\n",[10,1423,1424,1425,1428,1429,1431,1432,1434,1435,1437,1438,1440],{},"Setting it ",[99,1426,1427],{},"below"," ",[25,1430,1010],{}," is the detail that matters: the dump has to happen while the process is still alive. A ",[25,1433,1406],{}," of 45 with a ",[25,1436,1010],{}," of 60 gives fifteen seconds in which the stacks are written and flushed before ",[25,1439,84],{}," kills the process.",[10,1442,1443,1444,1447,1448,1451,1452,1455,1456,32],{},"The second line is ",[25,1445,1446],{},"-p no:cacheprovider"," plus ",[25,1449,1450],{},"-v"," in the CI invocation, so the log shows which test ",[99,1453,1454],{},"started"," even when none finished. A hung run whose last log line is the name of the offending test is a two-minute diagnosis; one that buffered its output and printed nothing is an hour of bisecting. The broader set of habits for making CI failures self-describing — artifact capture, container logs, structured output — is collected in ",[62,1457,1459],{"href":1458},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fcapturing-artifacts-from-a-failed-ci-test-run\u002F","capturing artifacts from a failed CI test run",[14,1461,1463],{"id":1462},"choosing-the-numbers","Choosing the numbers",[10,1465,1466],{},"Timeout values that cause flakiness are almost always set near the observed duration rather than an order of magnitude above it. The reasoning that produces a bad number is \"this takes 40 ms, so 200 ms is generous\" — which ignores that CI runners are shared, that a cold import can add a second, and that the same suite will one day run on a machine with a tenth of the cores.",[10,1468,1469,1470,1474],{},"A workable rule: the suite ceiling sits at ten to sixty seconds, high enough that no healthy test approaches it; per-test overrides are used sparingly and documented with the reason; and per-operation deadlines inside tests are chosen to be well above the healthy case but well below the suite ceiling, so that when they fire the failure is attributed to the operation rather than to the runner. Where a test genuinely needs to assert on a tight bound — \"the cache must answer in under 5 ms\" — that is a benchmark, not a test, and belongs with ",[62,1471,1473],{"href":1472},"\u002Fsystematic-debugging-performance-profiling\u002Fcpu-profiling-with-cprofile-and-py-spy\u002Fbenchmarking-with-pytest-benchmark\u002F","pytest-benchmark",", which measures repeatedly and reports distributions rather than failing on a single sample.",[10,1476,1477,1478,1482,1483,1486],{},"One more consideration applies to suites that run under ",[62,1479,1481],{"href":1480},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fpytest-xdist-vs-pytest-parallel-performance-comparison\u002F","pytest-xdist",". With eight workers on a four-core runner, every test is competing for CPU, and a bound calibrated on an idle machine will fire spuriously. The fix is not to raise every timeout but to measure under the configuration that actually runs in CI: run the suite locally with the same worker count, take the slowest observed duration from ",[25,1484,1485],{},"--durations=0",", and set the ceiling well clear of that. A timeout tuned against a serial run and deployed to a parallel one is the single most common source of a suite that is green locally and intermittently red in the pipeline. Record the chosen numbers and the measurement they came from in a comment next to the configuration, because the next person to see a spurious timeout will otherwise double it, and doubling an already-generous bound simply doubles how long a genuine hang takes to surface.",[14,1488,1490],{"id":1489},"frequently-asked-questions","Frequently Asked Questions",[10,1492,1493,1496,1497,1499,1500,1503,1504,1506,1507,1509,1510,1512],{},[78,1494,1495],{},"What is the difference between the signal and thread timeout methods?","\nThe ",[25,1498,265],{}," method uses ",[25,1501,1502],{},"SIGALRM",", works only on the main thread of a Unix process, and interrupts the running code with a traceback at the exact line. The ",[25,1505,269],{}," method runs a watchdog thread that dumps every thread's stack and then kills the process; it works on Windows and inside threads but cannot produce a normal test failure. Use ",[25,1508,265],{}," where it is available and ",[25,1511,269],{}," where it is not.",[10,1514,1515,1518,1519,1521,1522,1524],{},[78,1516,1517],{},"Why does my coroutine keep running after asyncio.timeout expires?","\nCancellation is delivered as an exception at the next await point. A coroutine that is blocked in synchronous code — a CPU loop, a blocking socket read, ",[25,1520,924],{}," — never reaches an await and therefore never sees the cancellation. Move blocking work to ",[25,1523,679],{}," so the loop keeps a suspension point available.",[10,1526,1527,1530,1531,1533],{},[78,1528,1529],{},"Should cleanup code be shielded from cancellation?","\nOnly the part that must complete, and only with a bound. ",[25,1532,721],{}," around a short release or rollback is legitimate; shielding a whole cleanup coroutine means a timeout cannot stop it, which reintroduces the hang the timeout existed to prevent. Shield the smallest possible region and give it its own deadline.",[10,1535,1536,1539],{},[78,1537,1538],{},"Is a global pytest timeout enough on its own?","\nNo. A suite-wide ceiling stops a hang from consuming the job, but it tells you nothing about whether the code's own timeout logic works. Per-operation deadlines inside tests assert the behaviour; the global ceiling is a safety net for the cases the assertions miss.",[10,1541,1542,1545],{},[78,1543,1544],{},"How do I choose a timeout value that is not flaky?","\nSet it an order of magnitude above the observed worst case, not just above the average. A test that normally takes 50 ms gets a 5-second ceiling: high enough that a slow runner never trips it, low enough that a genuine hang is caught in seconds rather than at the job limit.",[14,1547,1549],{"id":1548},"related-guides","Related guides",[19,1551,1552,1559,1566,1571,1578],{},[22,1553,1554,1555,32],{},"Configure the suite ceiling properly in ",[62,1556,1558],{"href":1557},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Ffailing-fast-with-pytest-timeout\u002F","failing fast with pytest-timeout",[22,1560,1561,1562,32],{},"Cover the cleanup paths with ",[62,1563,1565],{"href":1564},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Ftesting-cancellation-and-cleanup-paths\u002F","testing cancellation and cleanup paths",[22,1567,1568,1569,32],{},"Remove fixed waits using ",[62,1570,1026],{"href":1025},[22,1572,1573,1574,32],{},"Get a stack dump from a deadlocked threaded test with ",[62,1575,1577],{"href":1576},"\u002Ftesting-async-and-concurrent-python\u002Ftesting-threads-and-race-conditions\u002Fdumping-stacks-on-deadlock-with-faulthandler\u002F","dumping stacks on deadlock with faulthandler",[22,1579,1580,1581,32],{},"When the hang is an await that never resolves, trace it with ",[62,1582,1584],{"href":1583},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Ftracking-down-a-hung-await-with-task-stacks\u002F","tracking down a hung await with task stacks",[10,1586,1587,1588],{},"← Back to ",[62,1589,1591],{"href":1590},"\u002Ftesting-async-and-concurrent-python\u002F","Testing Async & Concurrent Python",[1593,1594,1595],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":220,"searchDepth":233,"depth":233,"links":1597},[1598,1599,1600,1607,1608,1609,1610,1611,1612,1613,1614,1615],{"id":16,"depth":233,"text":17},{"id":69,"depth":233,"text":70},{"id":207,"depth":233,"text":208,"children":1601},[1602,1603,1604,1605,1606],{"id":212,"depth":239,"text":213},{"id":272,"depth":239,"text":273},{"id":316,"depth":239,"text":317},{"id":398,"depth":239,"text":399},{"id":485,"depth":239,"text":486},{"id":555,"depth":233,"text":556},{"id":625,"depth":233,"text":626},{"id":757,"depth":233,"text":758},{"id":917,"depth":233,"text":918},{"id":1029,"depth":233,"text":1030},{"id":1393,"depth":233,"text":1394},{"id":1462,"depth":233,"text":1463},{"id":1489,"depth":233,"text":1490},{"id":1548,"depth":233,"text":1549},"Stop hung tests from eating CI budgets: pytest-timeout methods, asyncio.timeout scopes, cancellation semantics, shielded cleanup, and assertions that replace sleeps.","md",{"slug":1619,"type":1620,"breadcrumb":1621,"datePublished":1622,"dateModified":1622,"faq":1623,"howto":1634},"timeouts-cancellation-and-deadlines","topic","Timeouts & Cancellation","2026-09-18",[1624,1626,1628,1630,1632],{"q":1495,"a":1625},"The signal method uses SIGALRM, works only on the main thread of a Unix process, and interrupts the running code with a traceback at the exact line. The thread method runs a watchdog thread that dumps every thread's stack and then kills the process; it works on Windows and inside threads but cannot produce a normal test failure. Use signal where it is available and thread where it is not.",{"q":1517,"a":1627},"Cancellation is delivered as an exception at the next await point. A coroutine that is blocked in synchronous code — a CPU loop, a blocking socket read, time.sleep — never reaches an await and therefore never sees the cancellation. Move blocking work to asyncio.to_thread so the loop keeps a suspension point available.",{"q":1529,"a":1629},"Only the part that must complete, and only with a bound. asyncio.shield around a short release or rollback is legitimate; shielding a whole cleanup coroutine means a timeout cannot stop it, which reintroduces the hang the timeout existed to prevent. Shield the smallest possible region and give it its own deadline.",{"q":1538,"a":1631},"No. A suite-wide ceiling stops a hang from consuming the job, but it tells you nothing about whether the code's own timeout logic works. Per-operation deadlines inside tests assert the behaviour; the global ceiling is a safety net for the cases the assertions miss.",{"q":1544,"a":1633},"Set it an order of magnitude above the observed worst case, not just above the average. A test that normally takes 50 ms gets a 5-second ceiling: high enough that a slow runner never trips it, low enough that a genuine hang is caught in seconds rather than at the job limit.",{"name":1635,"description":1636,"steps":1637},"How to give a test suite reliable deadlines","Layer a suite-wide ceiling, per-test overrides and per-operation deadlines so a hang always produces a diagnosis.",[1638,1641,1644,1647,1650],{"name":1639,"text":1640},"Set a suite-wide ceiling","Add timeout and timeout_method to the pytest configuration so no test can run indefinitely, choosing the thread method where signals are unavailable.",{"name":1642,"text":1643},"Override the known-slow tests","Apply @pytest.mark.timeout(n) to the small number of tests that legitimately exceed the global ceiling instead of raising the ceiling for everyone.",{"name":1645,"text":1646},"Add per-operation deadlines inside tests","Wrap the operation under test in asyncio.timeout or anyio.fail_after so the assertion is about the code's timeout behaviour rather than the runner's.",{"name":1648,"text":1649},"Assert on post-cancellation state","After a deadline fires, assert that connections closed, locks released and buffers flushed, so cleanup paths are covered rather than assumed.",{"name":1651,"text":1652},"Replace every sleep with a condition","Swap fixed sleeps for events or bounded polling so the test waits exactly as long as the work takes.","\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines",{"title":5,"description":1616},"testing-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Findex","inVGBGsH97xJKwIY-4-Gigkq0q4WF1dcktP_u503WN0",1789718765722]