Skip to content

Commit 735b840

Browse files
badGarnetclaude
andcommitted
test: sequence the cancellation test with events, not sleeps
The test raced. It relied on a fixed `time.sleep(0.3)` in the worker outlasting an `await asyncio.sleep(0.05)` before `task.cancel()`. On a loaded runner the conversion could finish first, leaving nothing to cancel, and the test would fail with "DID NOT RAISE" -- a spurious failure rather than a real one. Now sequenced with three `threading.Event` handshakes: the worker signals that it has entered the conversion, the test cancels only after that, and only then releases the worker, so completion is necessarily post-cancellation. A spy on `_discard_elements_file` signals that cleanup ran, so the assertion waits on the actual event rather than polling the filesystem on a timer. The `wait` timeouts are deadlock guards, never a duration anything waits out. Also drops the 0.02s x 200 polling loop, so the test now takes ~0.15s instead of ~0.5s, and reports "cleanup never ran" instead of a bare assertion when it fails. Still confirmed to fail against the plain awaited to_thread. Ran 20x clean, and 10x clean under eight busy cores. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 19ca812 commit 735b840

1 file changed

Lines changed: 28 additions & 10 deletions

File tree

_test_unstructured_client/unit/test_ndjson_elements_file.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import os
1616
import tempfile
1717
import threading
18-
import time
1918
from pathlib import Path
2019
from unittest import mock
2120

@@ -40,6 +39,9 @@
4039
)
4140
from unstructured_client.models import errors, operations, shared
4241

42+
# Deadlock guard for the event handshakes below, never a value the tests wait out.
43+
TIMEOUT = 10
44+
4345

4446
def _elements(prefix, count):
4547
return [
@@ -763,31 +765,47 @@ async def test_async_conversion_cleans_up_when_cancelled(tmp_path, monkeypatch):
763765
Regression guard for the cost of the offload: a thread cannot be cancelled, so the
764766
conversion runs to completion regardless, and a plain `await asyncio.to_thread(...)`
765767
discards the path it returned -- leaving nothing that could delete the file.
768+
769+
Sequenced with events rather than sleeps. Timing the cancellation against a fixed
770+
sleep would race on a loaded runner: if the conversion finished first there would be
771+
nothing to cancel and the test would fail spuriously. The timeouts here are only
772+
deadlocks guards, never the thing being waited on.
766773
"""
767774
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
768775
created = _record_created_paths(monkeypatch, general, "_new_elements_file")
776+
777+
conversion_started = threading.Event()
778+
allow_conversion = threading.Event()
779+
cleanup_done = threading.Event()
769780
real_convert = general._json_body_to_elements_file
781+
real_discard = general._discard_elements_file
770782

771-
def _slow_convert(http_res):
772-
time.sleep(0.3)
783+
def _blocked_convert(http_res):
784+
conversion_started.set()
785+
assert allow_conversion.wait(TIMEOUT), "test never released the conversion"
773786
return real_convert(http_res)
774787

775-
monkeypatch.setattr(general, "_json_body_to_elements_file", _slow_convert)
788+
def _observed_discard(path):
789+
real_discard(path)
790+
cleanup_done.set()
791+
792+
monkeypatch.setattr(general, "_json_body_to_elements_file", _blocked_convert)
793+
monkeypatch.setattr(general, "_discard_elements_file", _observed_discard)
776794
response = httpx.Response(
777795
200, headers={"Content-Type": "application/json"}, json=[{"type": "Table"}]
778796
)
779797

780798
task = asyncio.ensure_future(general._json_body_to_elements_file_async(response))
781-
await asyncio.sleep(0.05)
799+
800+
# Waiting in a worker thread keeps the event loop free to run the task.
801+
assert await asyncio.to_thread(conversion_started.wait, TIMEOUT), "conversion never ran"
782802
task.cancel()
783803
with pytest.raises(asyncio.CancelledError):
784804
await task
785805

786-
# Let the shielded thread finish and its cleanup callback run.
787-
for _ in range(200):
788-
await asyncio.sleep(0.02)
789-
if created and not os.path.exists(created[0]):
790-
break
806+
# Only now let the conversion complete, so it necessarily finishes post-cancellation.
807+
allow_conversion.set()
808+
assert await asyncio.to_thread(cleanup_done.wait, TIMEOUT), "cleanup never ran"
791809

792810
# Non-vacuous: a file really was created, and it is now gone.
793811
assert len(created) == 1

0 commit comments

Comments
 (0)