Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<SID>)`) and stops only your processes; an opt-in
Expand All @@ -177,6 +178,10 @@ 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 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
Expand Down
8 changes: 4 additions & 4 deletions packaging/build-backend.lock
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions packaging/build-tools.lock
Original file line number Diff line number Diff line change
@@ -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 \
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packaging/flatpak/build-sources.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
8 changes: 6 additions & 2 deletions rnfs/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@
import re
import secrets
import socket
import socketserver
import threading
import time
from contextlib import contextmanager
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down
24 changes: 22 additions & 2 deletions rnfs/apiclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import os
import re
import secrets
import select
import stat
import threading
import time
Expand Down Expand Up @@ -679,14 +680,31 @@ 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:
"""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)
Expand All @@ -700,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()
Expand Down
4 changes: 3 additions & 1 deletion tests/ci_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1533,14 +1533,26 @@ 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."""

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)

Expand All @@ -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)
Expand Down
71 changes: 71 additions & 0 deletions tests/test_apiclient.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -282,6 +283,76 @@ 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_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)
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."""

Expand Down
6 changes: 5 additions & 1 deletion tests/test_daemon_wait.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion tests/test_public.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading