summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_partial_uploads.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_partial_uploads.py')
-rw-r--r--packages/meshbay-node/tests/test_partial_uploads.py360
1 files changed, 360 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_partial_uploads.py b/packages/meshbay-node/tests/test_partial_uploads.py
new file mode 100644
index 0000000..ea5637c
--- /dev/null
+++ b/packages/meshbay-node/tests/test_partial_uploads.py
@@ -0,0 +1,360 @@
+"""
+Two rules about an upload that stopped in the middle.
+
+**It belongs to the group, not to the connection.** Progress used to be kept on
+the session, so a dropped connection lost it and the client's next chunk was
+refused with `not_started` — an upload interrupted at 99% could only start again
+from zero, on a link flaky enough to have interrupted it once.
+
+**And what it leaves on disk has an owner or it has an end.** The state that was
+lost left a `.part` file nothing would ever finish, delete or look at again:
+invisible in the index, because `.part` is not an index entry, and a gigabyte of
+somebody else's disk for one abandoned film.
+
+The keying is a correctness property rather than a nicety: a shared directory
+means two members can be sending `IMG_1234.jpg` at the same moment, and neither
+may inherit — or overwrite the position of — the other's.
+"""
+
+import os
+import time
+import types
+from pathlib import Path
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import generate_gek
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roots import Root, RootSet
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+from conftest import one_root, sealed_upload
+
+from meshbay_node.uploads import (
+ ORPHAN_AFTER_SECS, PART_SUFFIX, PartialUploads, find_parts, orphaned_parts,
+)
+
+
+# ── the state ───────────────────────────────────────────────────────────────
+
+def test_an_upload_is_found_again_after_the_connection_went_away():
+ """The whole point: the store outlives the session, so the position is
+ still there when the client comes back."""
+ uploads = PartialUploads()
+ uploads.start("alice", "media", "film.mkv", "film.mkv")
+ uploads.advance("alice", "media", "film.mkv", chunk_index=0, nbytes=1024)
+ uploads.advance("alice", "media", "film.mkv", chunk_index=1, nbytes=1024)
+
+ state = uploads.get("alice", "media", "film.mkv")
+ assert state is not None
+ assert state.next_index == 2
+ assert state.bytes == 2048
+
+
+def test_two_members_uploading_the_same_name_do_not_share_a_position():
+ """A shared folder makes this ordinary, not adversarial: everyone's camera
+ produces the same filenames. Inheriting the other's position would append
+ one person's chunks to another person's file."""
+ uploads = PartialUploads()
+ uploads.start("alice", "photos", "IMG_1234.jpg", "IMG_1234.jpg")
+ uploads.start("bob", "photos", "IMG_1234.jpg", "IMG_1234 (2).jpg")
+ uploads.advance("alice", "photos", "IMG_1234.jpg", 0, 10)
+
+ assert uploads.get("alice", "photos", "IMG_1234.jpg").next_index == 1
+ assert uploads.get("bob", "photos", "IMG_1234.jpg").next_index == 0
+ assert uploads.get("bob", "photos", "IMG_1234.jpg").stored_name \
+ == "IMG_1234 (2).jpg"
+
+
+def test_the_same_name_in_two_directories_is_two_uploads():
+ uploads = PartialUploads()
+ uploads.start("alice", "media", "a.bin", "a.bin")
+ uploads.start("alice", "archive", "a.bin", "a.bin")
+ uploads.advance("alice", "media", "a.bin", 0, 5)
+ assert uploads.get("alice", "archive", "a.bin").next_index == 0
+
+
+def test_advancing_an_upload_nobody_started_says_so():
+ """The caller refuses the chunk on this; silently creating the state here
+ would let a client append to whatever `.part` is already on disk."""
+ assert PartialUploads().advance("alice", "media", "x", 0, 1) is None
+
+
+def test_starting_again_forgets_the_old_position():
+ """Chunk zero means "from the beginning" — the file is opened for writing,
+ not appending, so the position has to go with it."""
+ uploads = PartialUploads()
+ uploads.start("alice", "media", "a.bin", "a.bin")
+ uploads.advance("alice", "media", "a.bin", 0, 500)
+ uploads.start("alice", "media", "a.bin", "a.bin")
+ assert uploads.get("alice", "media", "a.bin").next_index == 0
+ assert uploads.get("alice", "media", "a.bin").bytes == 0
+
+
+# ── the reaper ──────────────────────────────────────────────────────────────
+
+def _old(seconds: float) -> float:
+ return 1_000_000.0 - seconds
+
+
+NOW = 1_000_000.0
+FILM = Path("/roots/media/film.mkv.part")
+
+
+def test_a_part_nobody_is_writing_and_nobody_has_touched_is_deleted():
+ """The leak this exists to close: an abandoned upload's file, kept for ever
+ and invisible because `.part` is not an index entry."""
+ doomed = orphaned_parts([(FILM, _old(ORPHAN_AFTER_SECS + 1))],
+ live=set(), now=NOW)
+ assert doomed == [FILM]
+
+
+def test_an_upload_in_progress_is_never_deleted():
+ """Even when its file is old: a large upload over a slow link is exactly the
+ one that has been on disk the longest, and it is the one that would hurt
+ most to lose."""
+ doomed = orphaned_parts([(FILM, _old(ORPHAN_AFTER_SECS * 3))],
+ live={FILM}, now=NOW)
+ assert doomed == []
+
+
+def test_a_recently_written_part_is_left_alone():
+ """No state and recent writes is a client that has just reconnected, or one
+ whose state this node has not seen yet. Waiting a day costs disk; being
+ wrong costs somebody their upload."""
+ doomed = orphaned_parts([(FILM, _old(60))], live=set(), now=NOW)
+ assert doomed == []
+
+
+def test_the_same_name_in_another_directory_does_not_protect_it():
+ """Matched on the whole path, so an upload to `media/` cannot keep an
+ orphan in `archive/` alive for ever. Comparing names would; comparing a
+ path rebuilt from a root and a relative directory would be a second
+ implementation that has to agree with the first for ever, and the state
+ records the path it is writing instead."""
+ other = Path("/roots/archive/film.mkv.part")
+ doomed = orphaned_parts([(other, _old(ORPHAN_AFTER_SECS + 1))],
+ live={FILM}, now=NOW)
+ assert doomed == [other]
+
+
+def test_a_finished_file_is_not_a_candidate():
+ """Only `.part` is ever deleted. A bug that let this touch a real file would
+ be the worst one in the project, so the check is here as well as at the call
+ site that only offers `.part` paths."""
+ doomed = orphaned_parts(
+ [(Path("/roots/media/film.mkv"), _old(ORPHAN_AFTER_SECS * 10))],
+ live=set(), now=NOW)
+ assert doomed == []
+
+
+def test_a_file_from_the_future_is_left_alone():
+ """A clock that went backwards is not evidence that a file is abandoned, and
+ deleting is not reversible."""
+ doomed = orphaned_parts([(Path("/roots/media/a.part"), NOW + 10_000)],
+ live=set(), now=NOW)
+ assert doomed == []
+
+
+def test_the_boundary_is_the_age_itself():
+ at = [(Path("/roots/media/a.part"), _old(ORPHAN_AFTER_SECS))]
+ just_under = [(Path("/roots/media/a.part"), _old(ORPHAN_AFTER_SECS - 1))]
+ assert orphaned_parts(at, set(), NOW) == [Path("/roots/media/a.part")]
+ assert orphaned_parts(just_under, set(), NOW) == []
+
+
+def test_an_upload_records_the_file_it_is_writing():
+ """What keeps the reaper honest. Without it the two sides would have to
+ agree on how a path is built from a root name and a relative directory —
+ two implementations of one rule, and the failure mode is deleting a live
+ upload."""
+ uploads = PartialUploads()
+ uploads.start("alice", "media", "film.mkv", "film.mkv", part_path=FILM)
+ assert uploads.live_paths() == {FILM}
+ uploads.drop("alice", "media", "film.mkv")
+ assert uploads.live_paths() == set()
+
+
+def test_the_suffix_is_named_once():
+ """Two spellings of `.part` would be a bug nobody could see: the writer
+ would produce one and the reaper would look for the other."""
+ assert PART_SUFFIX == ".part"
+
+
+# ── the walk, and the deletion ──────────────────────────────────────────────
+
+def _root(tmp_path, name, *, writable=True, available=True) -> Root:
+ path = tmp_path / name
+ path.mkdir(parents=True, exist_ok=True)
+ return Root(name=name, path=path, writable=writable, available=available)
+
+
+def _aged(path: Path, seconds: float, content: bytes = b"x") -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(content)
+ when = time.time() - seconds
+ os.utime(path, (when, when))
+ return path
+
+
+def test_the_walk_finds_parts_in_subdirectories(tmp_path):
+ """Uploads go into the folder the sender was looking at, which is any
+ directory in the group — not a quarantine subfolder, since 2026-08-14."""
+ root = _root(tmp_path, "media")
+ _aged(root.path / "a.part", 10)
+ _aged(root.path / "series" / "b.part", 10)
+ _aged(root.path / "series" / "kept.mkv", 10)
+ found = {p.name for p, _ in find_parts([root])}
+ assert found == {"a.part", "b.part"}
+
+
+def test_a_read_only_root_is_not_walked(tmp_path):
+ """It cannot have received an upload, so anything `.part` in it belongs to
+ the operator and is none of this code's business."""
+ root = _root(tmp_path, "library", writable=False)
+ _aged(root.path / "theirs.part", ORPHAN_AFTER_SECS * 2)
+ assert find_parts([root]) == []
+
+
+def test_an_unavailable_root_is_not_walked(tmp_path):
+ """A drive that is not mounted. Walking it finds nothing, and "nothing
+ found" is the input from which a careless janitor concludes everything is
+ gone."""
+ root = _root(tmp_path, "external", available=False)
+ _aged(root.path / "x.part", ORPHAN_AFTER_SECS * 2)
+ assert find_parts([root]) == []
+
+
+def _daemon(groups: dict) -> NodeDaemon:
+ """A daemon with nothing but what `_reap_once` reads."""
+ daemon = NodeDaemon.__new__(NodeDaemon)
+ daemon._webrtc = types.SimpleNamespace(_ctx={"groups": groups})
+ return daemon
+
+
+def test_the_janitor_deletes_the_abandoned_and_keeps_the_rest(tmp_path):
+ """End to end on real files: the old orphan goes, the recent one and the
+ one somebody is still writing stay, and a finished file is never a
+ candidate."""
+ root = _root(tmp_path, "media")
+ old = _aged(root.path / "abandoned.mkv.part", ORPHAN_AFTER_SECS + 60)
+ recent = _aged(root.path / "fresh.mkv.part", 30)
+ live = _aged(root.path / "sending.mkv.part", ORPHAN_AFTER_SECS * 2)
+ finished = _aged(root.path / "done.mkv", ORPHAN_AFTER_SECS * 5)
+
+ uploads = PartialUploads()
+ uploads.start("alice", "media", "sending.mkv", "sending.mkv", part_path=live)
+
+ daemon = _daemon({"g1": {"roots": RootSet(roots=[root]),
+ "partial_uploads": uploads}})
+ assert daemon._reap_once() == 1
+ assert not old.exists()
+ assert recent.exists() and live.exists() and finished.exists()
+
+
+def test_a_group_that_has_never_uploaded_anything_is_handled(tmp_path):
+ """No `partial_uploads` in the context yet — it is created on first use, so
+ a node that has been up for five minutes has none."""
+ root = _root(tmp_path, "media")
+ old = _aged(root.path / "left.mkv.part", ORPHAN_AFTER_SECS + 1)
+ daemon = _daemon({"g1": {"roots": RootSet(roots=[root])}})
+ assert daemon._reap_once() == 1
+ assert not old.exists()
+
+
+def test_a_group_with_no_roots_is_skipped(tmp_path):
+ assert _daemon({"g1": {}})._reap_once() == 0
+
+
+# ── across two connections ──────────────────────────────────────────────────
+
+GROUP = "g" * 32
+
+
+def _peer(ctx: dict, user_id: str = "user-1") -> WebRTCPeerSession:
+ """One connection into a group whose context is shared, as it is on a node.
+
+ Two of these standing for the same member is the whole point: the second is
+ the reconnection, and it must find what the first was doing.
+ """
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = GROUP
+ session._user_id = user_id
+ session._pk_user = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _group_ctx(tmp_path) -> dict:
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ return {"roots": one_root(shared),
+ "index": GroupIndex(group_id=GROUP,
+ sk_node=Ed25519PrivateKey.generate()),
+ "gek": generate_gek()}
+
+
+def _errors(session):
+ return [m for m in session.sent if m.get("type") == "error"]
+
+
+def test_an_upload_survives_the_connection_that_started_it(tmp_path):
+ """The defect this stage exists to fix.
+
+ The state used to live on the session, so the second connection saw no
+ upload at all and refused the chunk with `not_started`: an upload
+ interrupted at 99% could only be started again from zero, on a link flaky
+ enough to have interrupted it once.
+ """
+ ctx = _group_ctx(tmp_path)
+ first = _peer(ctx)
+ first._do_file_upload(sealed_upload(first, filename="film.mkv",
+ data=b"first-half",
+ chunk_index=0, total_chunks=2))
+ assert _errors(first) == []
+
+ # The link drops; the client comes back on a new connection and carries on.
+ second = _peer(ctx)
+ second._do_file_upload(sealed_upload(second, filename="film.mkv",
+ data=b"second-half",
+ chunk_index=1, total_chunks=2))
+ assert _errors(second) == [], _errors(second)
+
+ root = ctx["roots"].roots[0]
+ assert (root.path / "film.mkv").read_bytes() == b"first-halfsecond-half"
+
+
+def test_another_member_cannot_continue_somebody_elses_upload(tmp_path):
+ """The key includes the member for a reason. Without it, a second person
+ sending the same name into the same folder would append their chunks to the
+ first person's file — which a shared folder makes an ordinary accident, not
+ only an attack."""
+ ctx = _group_ctx(tmp_path)
+ alice = _peer(ctx, "alice")
+ alice._do_file_upload(sealed_upload(alice, filename="IMG_1234.jpg",
+ data=b"hers", chunk_index=0,
+ total_chunks=2))
+ assert _errors(alice) == []
+
+ bob = _peer(ctx, "bob")
+ bob._do_file_upload(sealed_upload(bob, filename="IMG_1234.jpg",
+ data=b"his", chunk_index=1,
+ total_chunks=2))
+ assert [m.get("code") for m in _errors(bob)] == ["not_started"]
+
+
+def test_an_upload_in_flight_is_known_to_the_reaper(tmp_path):
+ """The two halves of this stage meeting: the state the node keeps is what
+ stops the janitor deleting a file somebody is still sending."""
+ ctx = _group_ctx(tmp_path)
+ peer = _peer(ctx)
+ peer._do_file_upload(sealed_upload(peer, filename="film.mkv",
+ data=b"half", chunk_index=0,
+ total_chunks=2))
+ live = ctx["partial_uploads"].live_paths()
+ assert len(live) == 1
+ assert next(iter(live)).name == "film.mkv.part"
+ assert next(iter(live)).exists()