aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 14:28:40 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 14:28:40 +0200
commit7e2d078fe1870d256ae47781bee6ac4f454edf24 (patch)
tree05689049fd48f01ebbdb9995d5189cba15cee052 /packages/meshbay-common
parent813d18424ec57963bb56e6f40824a2db0ccce50d (diff)
parente6f895c473a0b19e7b186889c1836d3945bc880b (diff)
downloadmeshbay-7e2d078fe1870d256ae47781bee6ac4f454edf24.tar.gz
Merge branch 'fix/large-download-paths'
Concurrent-transfer limits, with the queue, the pause and the flag day. A node now caps how many transfers it runs at once (8 downloads, 8 uploads, node-wide) and how many one member may run in one group (2 by default, operator-signed). Beyond that the node answers "queued" and the client waits its turn, visibly, in the transfers panel — and a slot that frees starts whatever is next, skipping past a member who is at their own cap rather than letting them stall everyone behind them. Browsing is never subject to a slot: not the poster grid, not the covers, not opening a photo to look at it. That is structural — a transfer is what the transfers widget shows — and the exemption is bounded rather than open, at two files in flight per session, because an exemption with no bound is a leaseless branch under another name. Transfers can be cancelled, and now paused and resumed. A paused one holds nothing: its slot goes back at once and resuming rejoins the queue at the tail. Uploads survive the connection that started them and resume where the node stopped, asked for inside the seal rather than on a clear message. What they leave behind when they are abandoned is reaped, which closes a disk leak that predates this work. MNP 3.0 makes the lease compulsory and refuses 2.x at the handshake, with the desktop client checking `client.minimum` before connecting so an un-updated one says "update" instead of failing every connection in a protocol vocabulary. Fourteen defects were found on the way, eight of them by a person clicking Download and pasting a console — none of which 2075 tests could reach. Section 12 of ~/next/improve-downloads.md is that report, including the three this work introduced itself and the one that turned out to be caused by an instruction to hard-reload after each deployment. Node suite 1209 passed, hub suite 866 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-common')
-rw-r--r--packages/meshbay-common/pyproject.toml2
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py36
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py6
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py7
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py40
5 files changed, 87 insertions, 4 deletions
diff --git a/packages/meshbay-common/pyproject.toml b/packages/meshbay-common/pyproject.toml
index 13ade83..6a1370d 100644
--- a/packages/meshbay-common/pyproject.toml
+++ b/packages/meshbay-common/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-common"
-version = "0.12.0"
+version = "0.13.0"
description = "MeshBay shared cryptographic primitives and protocol types"
requires-python = ">=3.12"
dependencies = [
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index b64a6f2..1aaa269 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -1,6 +1,6 @@
"""MeshBay common — shared crypto primitives and protocol types."""
-__version__ = "0.12.0"
+__version__ = "0.13.0"
# 0.2: added PING/PONG, and `before`/`has_more` on chat history. Both are
# additive — an 0.1 peer sends no `before` and gets the newest page, which is
# what it wanted — so this is a MINOR bump, not a MAJOR one.
@@ -133,5 +133,37 @@ __version__ = "0.12.0"
# `index_progress` (counters only — see daemon.py `_push_index_progress`), the
# admin and configuration acks, and the media-metadata replies. The index at
# rest and file content on the operator's disk are unchanged.
-MNP_VERSION = "2.0"
+# **3.0 (2026-09-09): a transfer needs a lease, and a peer that cannot ask for
+# one is refused at the handshake.**
+#
+# `transfer_open` / `transfer_close` / `transfer_state` carry the lease a
+# download or an upload runs under; `file_req` gains an optional `tr` and
+# `file_upload` gains one beside the `upload_id` already in clear. All three are
+# in clear, like `index_progress` and for the same stated reason: `tr` is
+# opaque, `bytes` and `chunks` are numbers, and there is no filename and no path
+# anywhere in them. Putting one there to make a log line prettier is exactly the
+# trade `groupbox.py` exists to refuse.
+#
+# **The messages are additive; the requirement is not, and that is what makes
+# this MAJOR.** A 2.0 client sends no `tr`, so it is a leaseless reader — and a
+# leaseless reader is either refused as soon as it opens a third file, or it is
+# not refused and transfers outside every cap the operator set. An opt-in switch
+# ("enforce leases only for clients that speak 3.0") leaves that branch
+# reachable on every node, which is finding C6's lesson — a transport that
+# accepted a bare JWT — one feature later. It was already refused once, for chat
+# encryption, on 2026-09-07.
+#
+# Browsing is deliberately **not** leased and never will be: not the poster
+# grid, not the covers, not opening a photo to look at it. That exemption is
+# bounded rather than open (`transfers.LeaselessReads`, two files in flight per
+# session), because an exemption with no bound is the leaseless branch under
+# another name.
+#
+# **What it costs, stated plainly.** The SPA is served by the hub, so a browser
+# picks up the new client on reload. The desktop client ships its own UI, so an
+# un-updated one is locked out — which is why `GET /v1/hub/version` carries
+# `client.minimum` and the client checks it *before* connecting, and says "this
+# version can no longer connect" rather than showing a handshake refusal nobody
+# can act on.
+MNP_VERSION = "3.0"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index ed6e940..5c7345b 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -61,6 +61,12 @@ OP_APPS_ENABLED = "apps_enabled"
# security property in itself, but the pattern (every operator setting is
# signed) is what keeps the authorization model simple to reason about.
OP_SET_SCAN_SETTINGS = "set_scan_settings"
+# How many transfers one member may run at once in this group. Signed like the
+# rest: an unsigned cap is one any member can raise for themselves, which makes
+# the control a suggestion. The subject is "d=2,u=2" so what the operator is
+# shown before signing names the outcome and not the operation -- the same rule
+# member_upload's on/off subject follows.
+OP_TRANSFER_LIMITS = "transfer_limits"
# Whether the node uses the operator's own API token/language instead of the
# shipped default — node-wide (docs/mediacenter.md §5.5), one credential
# shared by every group. Signed like the rest: it turns on outbound
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index 188a8aa..65b4e85 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -78,7 +78,12 @@ HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"
# handshake and then discovering that every message it sends is rejected and
# every message it receives is unreadable. A stated refusal is a bug report; a
# chat that quietly does not work is a support case.
-MNP_MIN_SUPPORTED = "2.0"
+# 3.0 (2026-09-09): a transfer runs under a lease, and a 2.x peer cannot ask for
+# one. Admitting it would mean either refusing it later, per file, in a way it
+# has no vocabulary to understand — or serving it outside every cap the operator
+# set, which makes the caps decoration. Neither is honest, so it is refused
+# here, with a code and a sentence.
+MNP_MIN_SUPPORTED = "3.0"
ROLE_CLIENT = "client"
ROLE_NODE = "node"
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index e092353..5bd2903 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -96,6 +96,20 @@ class MNP:
# never serve the GEK in plaintext. Members obtain it by unwrapping their own
# ECIES bundle. The constants lingered after the handlers were deleted, leaving
# the wire contract looking as though the endpoint still existed.
+ # Transfer slots. A download is otherwise invisible to the node -- a series
+ # of independent file_req messages, with nothing saying one started or
+ # ended -- so there is nothing to count and nothing to cap. The lease is
+ # that missing object: `tr` is drawn by the client like `upload_id`, covers
+ # a job rather than a file, and dies with the connection.
+ #
+ # One reply type with a state field, not four: a client that must switch on
+ # the message type to discover it is still waiting is a client that will get
+ # one branch wrong. Carries no filename and no path -- `tr` is opaque,
+ # `bytes` and `chunks` are numbers -- so it stays in clear like
+ # INDEX_PROGRESS, for the same stated reason.
+ TRANSFER_OPEN = "transfer_open" # client -> node: I want a slot
+ TRANSFER_CLOSE = "transfer_close" # client -> node: I am done with it
+ TRANSFER_STATE = "transfer_state" # node -> client: granted/queued/closed
FILE_UPLOAD = "file_upload" # client pushes file chunk to node
FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt
DIR_CREATE = "dir_create" # client → node: make a directory
@@ -138,6 +152,8 @@ class MNP:
MEMBER_UPLOAD_ACK = "member_upload_ack"
APPS_ENABLED = "apps_enabled" # operator → node: which group apps to show
APPS_ENABLED_ACK = "apps_enabled_ack"
+ TRANSFER_LIMITS = "transfer_limits" # operator → node: per-member caps for this group
+ TRANSFER_LIMITS_ACK = "transfer_limits_ack" # node → this group: the new caps
SET_SCAN_SETTINGS = "set_scan_settings" # operator → node: reconcile/debounce timing
SET_SCAN_SETTINGS_ACK = "set_scan_settings_ack"
MEDIA_META_REQ = "media_meta_req" # client → node: TMDB metadata for a path
@@ -485,6 +501,22 @@ def file_upload_payload(gek: bytes, group_id: str, msg: dict) -> dict:
return unseal(gek, PURPOSE_UPLOAD, MNP.FILE_UPLOAD, group_id, msg)
+# "Where am I?", asked as an ordinary sealed upload chunk rather than as a new
+# message.
+#
+# The node identifies an upload by (member, directory, filename), so a client
+# resuming one has to name the file — and `transfer_open`, the obvious place to
+# ask, travels in clear. Naming it there would undo exactly what sealing the
+# upload path bought: before MNP 2.0 the same file was ciphertext leaving a node
+# and plaintext arriving at one.
+#
+# So the question is asked inside the seal that already exists, as a chunk with
+# no bytes and this index. The node writes nothing, changes nothing, and answers
+# with `resume_from`. A node that predates this refuses the index, which the
+# client reads as "start from the beginning" — the behaviour it had anyway.
+UPLOAD_PROBE_INDEX = -1
+
+
def file_upload_ack_wire(
gek: bytes,
group_id: str,
@@ -494,6 +526,7 @@ def file_upload_ack_wire(
filename: str,
stored_as: str,
dir: str = "",
+ resume_from: int | None = None,
) -> dict:
"""
The node's answer to one chunk, sealed the same way.
@@ -502,8 +535,15 @@ def file_upload_ack_wire(
replacing anything — and `dir` is where it landed. Both name the operator's
content, so both belong inside the seal; only `upload_id` and `chunk_index`
stay out, because the client matches on them.
+
+ `resume_from` answers the probe chunk (`UPLOAD_PROBE_INDEX`): how many
+ chunks of this file the node already holds. Inside the seal like the rest —
+ it is a fact about the operator's disk — and absent from an ordinary ack, so
+ a client can tell the two apart without looking at `chunk_index`.
"""
payload = {"filename": filename, "stored_as": stored_as, "dir": dir}
+ if resume_from is not None:
+ payload["resume_from"] = int(resume_from)
return {
"type": MNP.FILE_UPLOAD_ACK,
"v": MNP_VERSION,