From c85c7f48e8f29923038d4c90cc1a6f9b8bcd7673 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 9 Oct 2026 18:23:52 +0200 Subject: feat(node): refuse an upload on a full disk with a stated reason Nothing on the upload path knew about ENOSPC: a write that found no room raised out of the handler, the catch-all answered "Request failed", and the .part stayed behind holding the space that had run out. The node now refuses with `disk_full` at chunk 0 when the announced size would leave less than 1 GiB free, and at any write that fails with ENOSPC/EDQUOT, dropping the partial. The client carries the code on the error and the transfers panel says "The node's disk is full" in every catalogue. Co-Authored-By: Claude Opus 5.5 --- .../transport/webrtc/upload_handlers.py | 49 ++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py') diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py index 02a50af..b19846c 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py @@ -2,6 +2,7 @@ renamed into place under the name the node chose.""" import asyncio +import errno import logging from pathlib import Path @@ -16,7 +17,7 @@ from meshbay_node.roots import ( publish_upload, shell_active, ) -from meshbay_node.transport.webrtc.disk import _append_chunk +from meshbay_node.transport.webrtc.disk import _append_chunk, _free_bytes from meshbay_node.transport.webrtc.limits import LEASE_NONE, LEASE_QUEUED log = logging.getLogger("meshbay_node.transport.webrtc_server") @@ -34,6 +35,17 @@ log = logging.getLogger("meshbay_node.transport.webrtc_server") MAX_UPLOAD_BYTES = 8 * 1024 * 1024 * 1024 # 8 GB per file GB_BYTES = 1024 * 1024 * 1024 +# What an upload may never take from the operator's disk. A filesystem filled to +# its last byte is not only a refused upload: the node's own databases, the +# system's logs and whatever else the operator runs there start failing too. +# A gigabyte is noise beside any disk a group is hosted on and enough for those +# to keep writing. +DISK_RESERVE_BYTES = 1 * GB_BYTES + +# Both mean "this write found no room": a full filesystem, or the operator's own +# disk quota on it. Windows' ERROR_DISK_FULL arrives as ENOSPC too. +_NO_ROOM = frozenset({errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)}) + class UploadMixin: def _max_upload_bytes(self) -> int: @@ -354,6 +366,20 @@ class UploadMixin: if await off_disk(roots, final_path.exists): _refuse("File already exists", "already_exists") return + # Asked once, before the first byte, from the size the client + # announces: every chunk but the last is the size of this one, so + # this is an upper bound within one chunk. Peer-supplied, and that + # is fine — overstating it refuses only the sender's own upload. + # Without it a file that cannot fit is found out at the chunk that + # fills the disk, after the disk is full, and a nightly photo + # backup asks again every day. Concurrent uploads can each pass + # this; the ENOSPC below is what bounds them. + needed = max(0, total_chunks) * len(chunk_bytes) + if await off_disk(roots, _free_bytes, target_dir) - needed < DISK_RESERVE_BYTES: + log.warning("Upload refused: not enough free space in %s", rel_dir) + self._audit("upload_refused", "disk_full") + _refuse("The node's disk is full", "disk_full") + return state = uploads.start(user_id, rel_dir, filename, stored_name, part_path=tmp_path) elif state is None: @@ -372,7 +398,21 @@ class UploadMixin: _refuse("Upload exceeds size limit", "too_large") return - await off_disk(roots, _append_chunk, tmp_path, chunk_bytes, chunk_index == 0) + try: + await off_disk(roots, _append_chunk, tmp_path, chunk_bytes, chunk_index == 0) + except OSError as e: + if e.errno not in _NO_ROOM: + raise + # The partial is dropped with its state: it cannot be finished + # until the operator makes room, and keeping it holds the very + # space that ran out. The client starts over from zero, which is + # what it would have to do for a resume against a full disk anyway. + uploads.drop(user_id, rel_dir, filename) + await off_disk(roots, tmp_path.unlink, True) + log.warning("Upload refused: the disk holding %s is full", rel_dir) + self._audit("upload_refused", "disk_full") + _refuse("The node's disk is full", "disk_full") + return uploads.advance(user_id, rel_dir, filename, chunk_index, len(chunk_bytes)) last = chunk_index + 1 >= total_chunks @@ -387,7 +427,10 @@ class UploadMixin: uploads.reserved_names(rel_dir)) except OSError as e: log.warning("Upload %s could not be published: %s", stored_name, e) - _refuse("The file could not be stored", "store_failed") + if e.errno in _NO_ROOM: + _refuse("The node's disk is full", "disk_full") + else: + _refuse("The file could not be stored", "store_failed") return final_path = target_dir / stored_name -- cgit v1.2.3