From a2e80638bd036883a595952c3e0b8c9d56647f9c Mon Sep 17 00:00:00 2001 From: Afi Date: Sat, 24 Oct 2026 10:25:49 +1030 Subject: [PATCH 1/2] Fix the 0.2.1 PR's CI failures: setuptools 84.1.0, tests that assumed their runner CI on the 0.2.1 PR failed in six places, four of them already failing on dev before the audit fixes: - The new OSV audit found GHSA-xmj3-9gf7-h52w (low) in setuptools 84.0.0: the build locks and the Flatpak's build sources now pin 84.1.0 (packaging/update-lock.py --build, same pins otherwise). - test_e2e.RestartTest expected started_by "unknown", but a GitHub runner's jobs run inside its systemd service (INVOCATION_ID and a .service cgroup), so the daemon rightly said "systemd". The end-to-end daemons are now started without INVOCATION_ID or RNFS_STARTED_BY (daemon_environment). - test_tar_stream's cancelled upload: on Windows the send cut off by the daemon's 409 raises ConnectionAbortedError (WSAECONNABORTED), which ApiConnection._early_answer didn't take as a dropped upload, so the client reported a dropped connection instead of the 409. It now does, as for a broken pipe or a reset. - test_public's event watch waited for the add's pin event to be the last one heard; the add's job events may come after it. It now waits for the pin event anywhere after the "lost" marker. - test_api.NameWatchExpiryTest gave a 0.2 s grace with 0.1 s between the watches, which a slow macOS runner overran: now 2 s and 0.5 s. - test_daemon_wait's daemon didn't answer within 60 s on macOS (new with the [::1] listeners). HTTPServer's server_bind looks the address up (socket.getfqdn), which for ::1 can wait on the resolver; BoundedHTTPServer now binds without it (nothing reads server_name), and the test prints the daemon's output if it doesn't start. tests/ci_run.py ran a failed subtest again by its own id, which carries its parameters and loads as an error; it now runs it again by its test's id. --- CHANGELOG.md | 6 +++++- packaging/build-backend.lock | 8 ++++---- packaging/build-tools.lock | 8 ++++---- packaging/flatpak/build-sources.json | 4 ++-- rnfs/api.py | 8 ++++++-- rnfs/apiclient.py | 5 +++-- tests/ci_run.py | 4 +++- tests/test_api.py | 22 +++++++++++++++++----- tests/test_apiclient.py | 24 ++++++++++++++++++++++++ tests/test_daemon_wait.py | 6 +++++- tests/test_e2e.py | 9 ++++++++- tests/test_public.py | 4 +++- 12 files changed, 84 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a00b44..7ac2ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,7 +154,8 @@ Security (the 0.2.1 audit, AUD: each fix has a regression test in `tests/securit - **An embedded node's event watch is bounded** (AUD-L13, section 11): `events.watch` holds at most 1024 events waiting for a slow callback; past that the oldest are dropped and the callback gets a "lost" Event in their place, as a Client's watch does. Remote announces drive some event kinds. -- **Supply chain** (AUD-L14 to L18): certifi 2026.10.19 in `build-tools.lock` (GHSA-6q48-6c7w-9gw2), and a daily OSV +- **Supply chain** (AUD-L14 to L18): certifi 2026.10.19 in `build-tools.lock` (GHSA-6q48-6c7w-9gw2) and setuptools + 84.1.0 in both build locks (GHSA-xmj3-9gf7-h52w, which the new audit found), and a daily OSV audit of every lock (`.github/workflows/audit.yml`), since Dependabot doesn't read them; install.sh refuses to downgrade rnfs, or to replace a `--main`/`RNFS_SOURCE` install with a release of the same version, unless `--force`; the Windows logon task is per user (`rnfs daemon ()`) and stops only your processes; an opt-in @@ -177,6 +178,9 @@ Fixed: syncs and renames once complete; an export job resumes as a get job does. - **A finished job lets go of what it met** (AUD-M9): it kept every CID its last run met (about 175 bytes a block, 35 MB for a 12 GiB fetch) until it was forgotten. +- **Windows: an upload the daemon refuses or cancels reports why:** `rnfs.Client` read the daemon's answer to an + upload it stopped reading (409 cancelled, a refusal) only after a broken pipe or a reset; Windows reports that + send as aborted, so the client said the daemon dropped the connection. - **A job whose thread can't start is queued again** (AUD-L11), instead of staying "running" with no thread and holding a `max_jobs` slot until a restart; the jobs' retry loop survives a failing round. - **`install.sh --update --service` runs the version it installed:** with the service already running, the diff --git a/packaging/build-backend.lock b/packaging/build-backend.lock index 6bb6cf7..234ad63 100644 --- a/packaging/build-backend.lock +++ b/packaging/build-backend.lock @@ -1,8 +1,8 @@ # rnfs's build backend (setuptools), with the sha256 of every file PyPI has for it. Generated by # packaging/update-lock.py; don't edit by hand: -# python3 -I packaging/update-lock.py --build setuptools==84.0.0 build==1.6.1 twine==7.0.0 pyinstaller==6.22.3 ruff==0.17.0 more-itertools==11.1.0 certifi==2026.10.19 coverage==7.16.2 importlib-metadata==9.0.1 pyinstaller-hooks-contrib==2026.8 zipp==4.1.1 +# python3 -I packaging/update-lock.py --build setuptools==84.1.0 build==1.6.1 twine==7.0.0 pyinstaller==6.22.3 ruff==0.17.0 more-itertools==11.1.0 certifi==2026.10.19 coverage==7.16.2 importlib-metadata==9.0.1 pyinstaller-hooks-contrib==2026.8 zipp==4.1.1 # Install with: pip install --require-hashes --only-binary=:all: -r packaging/build-backend.lock # then build with: pip wheel --no-build-isolation --no-deps . -setuptools==84.0.0 \ - --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ - --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 +setuptools==84.1.0 \ + --hash=sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0 \ + --hash=sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9 diff --git a/packaging/build-tools.lock b/packaging/build-tools.lock index 95e567c..3b5e418 100644 --- a/packaging/build-tools.lock +++ b/packaging/build-tools.lock @@ -1,7 +1,7 @@ # The tools the workflows build, check, lint and measure coverage with (build, twine, PyInstaller, setuptools, # ruff, coverage) and their dependencies, for CPython 3.11, 3.12, 3.13, 3.14 on Linux x86_64 and Windows x64, with the # sha256 of every file PyPI has for each. Generated by packaging/update-lock.py; don't edit by hand: -# python3 -I packaging/update-lock.py --build setuptools==84.0.0 build==1.6.1 twine==7.0.0 pyinstaller==6.22.3 ruff==0.17.0 more-itertools==11.1.0 certifi==2026.10.19 coverage==7.16.2 importlib-metadata==9.0.1 pyinstaller-hooks-contrib==2026.8 zipp==4.1.1 +# python3 -I packaging/update-lock.py --build setuptools==84.1.0 build==1.6.1 twine==7.0.0 pyinstaller==6.22.3 ruff==0.17.0 more-itertools==11.1.0 certifi==2026.10.19 coverage==7.16.2 importlib-metadata==9.0.1 pyinstaller-hooks-contrib==2026.8 zipp==4.1.1 # Install with: pip install --require-hashes --only-binary=:all: -r packaging/build-tools.lock altgraph==0.17.5 \ --hash=sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7 \ @@ -609,9 +609,9 @@ ruff==0.17.0 \ secretstorage==3.5.0 ; sys_platform == "linux" \ --hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 \ --hash=sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be -setuptools==84.0.0 \ - --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ - --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 +setuptools==84.1.0 \ + --hash=sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0 \ + --hash=sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9 twine==7.0.0 \ --hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \ --hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7 diff --git a/packaging/flatpak/build-sources.json b/packaging/flatpak/build-sources.json index bf333c1..0e89e87 100644 --- a/packaging/flatpak/build-sources.json +++ b/packaging/flatpak/build-sources.json @@ -1,7 +1,7 @@ [ { "type": "file", - "url": "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", - "sha256": "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" + "url": "https://files.pythonhosted.org/packages/18/02/b4bbb90f8f29e334898ff45c6d9b1fffaa6f378017052b3023a06262b5f7/setuptools-84.1.0-py3-none-any.whl", + "sha256": "5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9" } ] diff --git a/rnfs/api.py b/rnfs/api.py index 1b81895..84be8ec 100644 --- a/rnfs/api.py +++ b/rnfs/api.py @@ -178,6 +178,7 @@ import re import secrets import socket +import socketserver import threading import time from contextlib import contextmanager @@ -579,7 +580,9 @@ def __init__(self, address, handler_class, max_connections: int, sibling: "Bound super().__init__(address, handler_class) def server_bind(self): - """Bind the listening socket, exclusively on Windows; an IPv6 one to IPv6 only.""" + """Bind the listening socket, exclusively on Windows; an IPv6 one to IPv6 only. The server's name is its + address as it is: HTTPServer's own server_bind looks the address up (socket.getfqdn), which for ::1 can + wait on the system's resolver (mDNS on macOS) before the daemon answers anything, and nothing reads it.""" if fsutil.IS_WINDOWS: # There SO_REUSEADDR (HTTPServer's default) lets another socket bind a port already listening, so a # second daemon, or another local program listening in on API tokens, could share it @@ -589,7 +592,8 @@ def server_bind(self): if self.address_family == socket.AF_INET6: # ::1 and nothing else: 127.0.0.1 is the sibling's self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) - super().server_bind() + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] def process_request(self, request, client_address): """Serve the connection on a new thread if a slot is free, else refuse it at once.""" diff --git a/rnfs/apiclient.py b/rnfs/apiclient.py index fa8f2b1..1145d5f 100644 --- a/rnfs/apiclient.py +++ b/rnfs/apiclient.py @@ -684,9 +684,10 @@ def _early_answer(connection: http.client.HTTPConnection, method: str, error: Exception) -> http.client.HTTPResponse | None: """The answer the daemon sent before it stopped reading an upload (a refusal, or the upload's job cancelled: 409 cancelled) and closed the connection, which sending the rest of the body then failed on; None if none - can be read (not a dropped connection, or nothing came).""" + can be read (not a dropped connection, or nothing came). Windows reports that send as aborted (WSAECONNABORTED), + where other systems report a broken pipe or a reset.""" sock = connection.sock - if sock is None or not isinstance(error, (BrokenPipeError, ConnectionResetError)): + if sock is None or not isinstance(error, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): return None try: response = http.client.HTTPResponse(sock, method=method) diff --git a/tests/ci_run.py b/tests/ci_run.py index 38630d2..ac63903 100644 --- a/tests/ci_run.py +++ b/tests/ci_run.py @@ -22,7 +22,9 @@ def rerun_name(test) -> str | None: - """The name to run test again by, or None if running it again can't help (it didn't import).""" + """The name to run test again by, or None if running it again can't help (it didn't import). A failed subtest + is run again by its test's name: its own id carries its parameters, which no loader takes.""" + test = getattr(test, "test_case", test) # a unittest.case._SubTest's test name = test.id() if name.startswith("unittest.loader."): return None diff --git a/tests/test_api.py b/tests/test_api.py index a4de08d..8e334cd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1533,6 +1533,18 @@ def add_record_listener(self, listener): pass +class NameLookupTest(unittest.TestCase): + """The API and the gateway listen without looking their addresses up: HTTPServer's server_bind calls + socket.getfqdn, which for ::1 can wait on the resolver (mDNS on macOS) while a starting daemon answers nothing.""" + + def test_no_name_lookup(self): + from rnfs.config import Config + from rnfs.gateway import GatewayServer + with mock.patch.object(socket, "getfqdn", side_effect=AssertionError("a name lookup")): + for server in (ApiServer(FakeNode(), 0), GatewayServer(FakeNode(), Config(), 0)): + self.addCleanup(server.stop) + + class NameWatchExpiryTest(unittest.TestCase): """A name watched for an API client stops being watched WATCH_GRACE after its last call, with no later call to notice, and when the server stops.""" @@ -1540,7 +1552,7 @@ class NameWatchExpiryTest(unittest.TestCase): def setUp(self): self.node = FakeNode() self.node.names = WatchedNames() - patcher = mock.patch.object(api, "WATCH_GRACE", 0.2) + patcher = mock.patch.object(api, "WATCH_GRACE", 2.0) # well past the sleep below, even on a slow runner patcher.start() self.addCleanup(patcher.stop) @@ -1555,14 +1567,14 @@ def test_a_watch_ends_after_its_grace_without_another_call(self): self.addCleanup(watches.close) name, other = bytes(16), bytes([1] * 16) watches.wait([name], [None], 0) - time.sleep(0.1) + time.sleep(0.5) # other's watch ends this much later than name's watches.wait([other], [None], 0) # ends later: the timer, armed for name, re-arms for it self.assertEqual(set(self.node.names.watched), {name, other}) - self.assertTrue(wait_for(lambda: name not in self.node.names.watched, 2)) + self.assertTrue(wait_for(lambda: name not in self.node.names.watched, 5)) self.assertIn(other, self.node.names.watched) - self.assertTrue(wait_for(lambda: not self.node.names.watched, 2)) + self.assertTrue(wait_for(lambda: not self.node.names.watched, 5)) self.assertEqual(watches.until, {}) - self.assertTrue(wait_for(lambda: not self.timing(watches), 2), "the timer ends once nothing is watched") + self.assertTrue(wait_for(lambda: not self.timing(watches), 5), "the timer ends once nothing is watched") def test_stopping_the_server_ends_the_watches_and_the_timer(self): server = ApiServer(self.node, 0) diff --git a/tests/test_apiclient.py b/tests/test_apiclient.py index fe17125..47f2e4f 100644 --- a/tests/test_apiclient.py +++ b/tests/test_apiclient.py @@ -282,6 +282,30 @@ def test_removed_only_by_its_own_daemon(self): self.assertFalse(os.path.exists(self.path)) +class EarlyAnswerTest(unittest.TestCase): + """The answer a daemon sent before it stopped reading an upload is read, whichever error sending the rest + raised: a broken pipe or a reset, or on Windows an abort (WSAECONNABORTED), which test_tar_stream's cancelled + upload got there.""" + + def test_each_dropped_send_reads_the_answer(self): + for error in (BrokenPipeError(), ConnectionResetError(), ConnectionAbortedError()): + with self.subTest(error=type(error).__name__): + ours, daemons = socket.socketpair() + self.addCleanup(ours.close) + with daemons: + daemons.sendall(b"HTTP/1.1 409 Conflict\r\nContent-Length: 2\r\n\r\n{}") + connection = mock.Mock(sock=ours) + response = ApiConnection._early_answer(connection, "POST", error) + self.assertIsNotNone(response) + self.assertEqual((response.status, response.read()), (409, b"{}")) + + def test_other_errors_read_nothing(self): + ours, daemons = socket.socketpair() + self.addCleanup(ours.close) + self.addCleanup(daemons.close) + self.assertIsNone(ApiConnection._early_answer(mock.Mock(sock=ours), "POST", TimeoutError())) + + class AppSupportTest(ClientTestCase): """The client side of what 0.2.0 added for apps: error codes, capabilities, events and shutdown.""" diff --git a/tests/test_daemon_wait.py b/tests/test_daemon_wait.py index 68d0b35..adf6b7f 100644 --- a/tests/test_daemon_wait.py +++ b/tests/test_daemon_wait.py @@ -140,7 +140,11 @@ def test_daemon_stops_on_sigterm_and_sigint(self): "--rnsconfig", rnfs_dir, "--gateway-port", str(free_port())], cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, env={**os.environ, "PYTHONUNBUFFERED": "1"}) try: - wait_for(lambda: self.status(rnfs_dir, api_port, proc), 60, "the daemon to start") + try: + wait_for(lambda: self.status(rnfs_dir, api_port, proc), 60, "the daemon to start") + except AssertionError as e: + with open(log_path, "rb") as f: + raise AssertionError(f"{e}; its output:\n{f.read().decode('utf-8', 'replace')}") from None time.sleep(1.5) # past the first waits of every loop start = time.monotonic() proc.send_signal(signum) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b1cc98b..e6e5b0b 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -89,6 +89,13 @@ def same_tree(a: str, b: str) -> bool: return all(same_tree(os.path.join(a, name), os.path.join(b, name)) for name in comparison.common_dirs) +def daemon_environment() -> dict: + """The environment a daemon is started with: this one, less what would say a service manager started it (a CI + runner's jobs run inside its systemd service, with INVOCATION_ID set), so each daemon is started by hand.""" + environment = {key: value for key, value in os.environ.items() if key not in ("INVOCATION_ID", "RNFS_STARTED_BY")} + return {**environment, "PYTHONUNBUFFERED": "1"} + + class Daemon: """One running daemon: its directory, ports, node ID and log.""" @@ -108,7 +115,7 @@ def __init__(self, name: str, base: str, config: str, tcp_port: int, *extra: str self.proc = subprocess.Popen( [sys.executable, MAIN, "--dir", self.dir, "--api-port", str(self.api_port), "daemon", "--rnsconfig", self.dir, "--gateway-port", str(self.gateway_port), *extra], - stdout=log, stderr=subprocess.STDOUT, env={**os.environ, "PYTHONUNBUFFERED": "1"}, **POPEN_OPTIONS) + stdout=log, stderr=subprocess.STDOUT, env=daemon_environment(), **POPEN_OPTIONS) self.node_id = wait_for(self.status, 60, f"{name} daemon to start")["node_id"] def status(self) -> dict | None: diff --git a/tests/test_public.py b/tests/test_public.py index ead11e0..19718ca 100644 --- a/tests/test_public.py +++ b/tests/test_public.py @@ -1014,7 +1014,9 @@ def test_event_watch_hears_a_restart_as_lost(self): lost = next(event for event in heard if event.lost) self.assertEqual(lost.fields["epoch"], self.b.events.epoch) cid = self.rnfs.add(b"after the restart").cid - wait_for(lambda: heard[-1].kind == "pin" and heard[-1].fields["cids"] == [str(cid)], what="the new event") + # After the marker: the add's pin event (its job's events may come before or after it) + wait_for(lambda: any(event.kind == "pin" and event.fields["cids"] == [str(cid)] + for event in heard[heard.index(lost) + 1:]), what="the new event") def test_a_watch_begun_in_a_new_run_leaves_older_watches_hearing(self): """A daemon restarting while the poller waits to retry, and a new watch begun in the new run: the older From 20711f3be20725aa93756134d300ac2ce0673548 Mon Sep 17 00:00:00 2001 From: Afi Date: Sat, 24 Oct 2026 12:19:29 +1030 Subject: [PATCH 2/2] Enhance upload handling: stop sending data once the daemon answers, improving response management on Windows. --- CHANGELOG.md | 5 +++-- rnfs/apiclient.py | 19 +++++++++++++++++ tests/test_apiclient.py | 47 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac2ab2..e8c4f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,8 +179,9 @@ Fixed: - **A finished job lets go of what it met** (AUD-M9): it kept every CID its last run met (about 175 bytes a block, 35 MB for a 12 GiB fetch) until it was forgotten. - **Windows: an upload the daemon refuses or cancels reports why:** `rnfs.Client` read the daemon's answer to an - upload it stopped reading (409 cancelled, a refusal) only after a broken pipe or a reset; Windows reports that - send as aborted, so the client said the daemon dropped the connection. + upload it stopped reading (409 cancelled, a refusal) only once sending the rest failed, but Windows discards that + answer unread when the daemon closes the connection, so the client said the daemon dropped the connection. It + now stops sending as soon as the daemon has answered, and reads the answer. - **A job whose thread can't start is queued again** (AUD-L11), instead of staying "running" with no thread and holding a `max_jobs` slot until a restart; the jobs' retry loop survives a failing round. - **`install.sh --update --service` runs the version it installed:** with the service already running, the diff --git a/rnfs/apiclient.py b/rnfs/apiclient.py index 1145d5f..9af4dbe 100644 --- a/rnfs/apiclient.py +++ b/rnfs/apiclient.py @@ -56,6 +56,7 @@ import os import re import secrets +import select import stat import threading import time @@ -679,6 +680,22 @@ def _proof_timeout(timeout: float | None) -> float: shorter (a listener mustn't hold a request longer than the caller allows it in all).""" return PROOF_TIMEOUT if timeout is None else min(timeout, PROOF_TIMEOUT) + @staticmethod + def _until_answered(connection: http.client.HTTPConnection, body): + """A binary file's bytes, in the connection's blocks, until the daemon answers: an upload's route answers only + once the body is read, unless it stops reading (a refusal, or the upload's job cancelled: 409 cancelled), so + anything to read before then is that answer, and the rest isn't sent. Where the daemon then closes the + connection while bytes still come, Windows discards the answer unread (WSAECONNABORTED), as other systems + don't (_early_answer).""" + while True: + sock = connection.sock + if sock is not None and select.select([sock], [], [], 0)[0]: + return + data = body.read(connection.blocksize) + if not data: + return + yield data + @staticmethod def _early_answer(connection: http.client.HTTPConnection, method: str, error: Exception) -> http.client.HTTPResponse | None: @@ -701,6 +718,8 @@ def _exchange(self, connection: http.client.HTTPConnection, method: str, url: st """Send one request on connection and return its response, whatever its status; a connection that fails raises DaemonNotRunning (DaemonStarting while a process holds the rnfs dir) or ConnectionFailed (also when the answer doesn't come within its timeout).""" + if hasattr(body, "read"): + body = self._until_answered(connection, body) try: connection.request(method, url, body=body, headers=headers) return connection.getresponse() diff --git a/tests/test_apiclient.py b/tests/test_apiclient.py index 47f2e4f..2d795ac 100644 --- a/tests/test_apiclient.py +++ b/tests/test_apiclient.py @@ -1,4 +1,5 @@ """Tests for the client library of the local API (rnfs/apiclient.py), against a live ApiServer with a FakeNode.""" +import http.client import http.server import inspect import io @@ -299,6 +300,52 @@ def test_each_dropped_send_reads_the_answer(self): self.assertIsNotNone(response) self.assertEqual((response.status, response.read()), (409, b"{}")) + def test_an_upload_stops_once_the_daemon_has_answered(self): + # A daemon that answers 409 mid-upload and keeps reading: the client sees the answer, sends no more of a + # body that never ends, and reads it (on Windows the daemon's close would have discarded it unread) + listener = socket.create_server(("127.0.0.1", 0)) + self.addCleanup(listener.close) + stop = threading.Event() + self.addCleanup(stop.set) + + def daemon(): + connection, _ = listener.accept() + with connection: + connection.settimeout(0.2) + received = b"" + while b"\r\n\r\n" not in received: + received += connection.recv(65536) + connection.sendall(b"HTTP/1.1 409 Conflict\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + while not stop.is_set(): # drains whatever still comes + try: + if not connection.recv(65536): + return + except TimeoutError: + pass + threading.Thread(target=daemon, daemon=True).start() + + class Endless(io.RawIOBase): + def readable(self): + return True + + def readinto(self, buffer): + buffer[:len(buffer)] = b"x" * len(buffer) + return len(buffer) + connection = http.client.HTTPConnection("127.0.0.1", listener.getsockname()[1], timeout=10) + self.addCleanup(connection.close) + connection.connect() + done = {} + + def upload(): + body = ApiConnection._until_answered(connection, io.BufferedReader(Endless())) + connection.request("POST", "/api/add/stream", body=body) + done["response"] = connection.getresponse() + thread = threading.Thread(target=upload, daemon=True) + thread.start() + thread.join(10) + self.assertFalse(thread.is_alive(), "the client kept sending after the daemon answered") + self.assertEqual((done["response"].status, done["response"].read()), (409, b"{}")) + def test_other_errors_read_nothing(self): ours, daemons = socket.socketpair() self.addCleanup(ours.close)