diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/config.py | 66 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 60 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 85 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 53 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 41 |
5 files changed, 235 insertions, 70 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 086d800..2ae3c7c 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -67,6 +67,13 @@ max_concurrent_streams = 8 max_concurrent_downloads = 8 max_concurrent_uploads = 8 +# The largest single file a member may upload to this node, in GB. It is this +# machine's disk that fills, so the ceiling is the operator's to set: lower it +# on a small disk, raise it for a library of films. Fractions are allowed +# (0.5 = 512 MB). A file past it is refused at the chunk that crosses it and +# the partial file is deleted. +max_upload_gb = 8 + # HEVC sources have no browser decoder on most platforms, so streaming one is # transcoded to H264 rather than the usual free copy — real CPU per viewer. # Set to false only if every viewer's client is known to decode HEVC itself. @@ -181,6 +188,11 @@ class NodeConfig: # answer "server busy". See meshbay_node/transfers.py. max_concurrent_downloads: int = 8 max_concurrent_uploads: int = 8 + # The per-file upload ceiling, in GB — the one limit here that bounds a + # member's writes to the operator's disk rather than this node's own + # concurrency. There is still no aggregate quota (MESHBAY_DESIGN.md + # §15.3), so this is what stands between a writable root and a full disk. + max_upload_gb: float = 8.0 # HEVC (and any future codec in media_probe.py's # BROWSER_INCOMPATIBLE_VIDEO_CODECS) has no decoder in most browsers, so # streaming it needs a real re-encode to H264 rather than the usual free @@ -301,6 +313,28 @@ def _positive(value: object, default: int, name: str) -> int: return n +def _positive_float(value: object, default: float, name: str) -> float: + """A size that must be greater than zero, or the default with a word about it. + + Separate from `_positive` because this one is a quantity, not a count: half + a gigabyte is a legitimate ceiling on a small disk, and rounding it to zero + would refuse every upload with nothing in the log to say why. + """ + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + log.warning("%s = %r is not a size — using %g", name, value, default) + return default + try: + n = float(value) + except (TypeError, ValueError): + log.warning("%s = %r is not a number — using %g", name, value, default) + return default + if n <= 0: + log.warning("%s = %g would refuse every upload — using %g", + name, n, default) + return default + return n + + def _read_roots(group: dict) -> list[RootSpec]: """ A group's roots, from `[[groups.roots]]` or from the legacy `shared_dir`. @@ -332,6 +366,35 @@ def _read_roots(group: dict) -> list[RootSpec]: return specs +def node_settings_defaults(nd: NodeConfig | None = None) -> dict: + """ + The `node.toml` side of every setting the roster resolves. + + One function because there were three copies of this dict written by hand + and they disagreed. The daemon's left out both transfer pools, so on a node + whose operator had never touched the panel they resolved to None, were + written back onto the config, and the transport fell through to its own + defaults — `node.toml` parsed, validated, and then ignored. + + `test_node_settings_defaults.py` holds this against + `Roster.node_setting_keys()`, because a key missing here raises nothing + anywhere: it is a setting that stops working quietly. + """ + nd = nd or NodeConfig() + return { + "invite_ttl_hours": nd.invite_ttl_hours, + "pair_ttl_hours": nd.pair_ttl_hours, + "device_request_ttl_minutes": nd.device_request_ttl_minutes, + "max_concurrent_streams": nd.max_concurrent_streams, + "max_concurrent_downloads": nd.max_concurrent_downloads, + "max_concurrent_uploads": nd.max_concurrent_uploads, + "max_upload_gb": nd.max_upload_gb, + "transcode_incompatible_video": nd.transcode_incompatible_video, + "stun_servers": nd.stun_servers or list(DEFAULT_STUN_SERVERS), + "ice_interfaces": nd.ice_interfaces, + } + + def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: """ Load config from TOML file. Supports both single [group] and @@ -368,6 +431,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: cfg.node.max_concurrent_uploads = _positive( nd.get("max_concurrent_uploads", cfg.node.max_concurrent_uploads), cfg.node.max_concurrent_uploads, "max_concurrent_uploads") + cfg.node.max_upload_gb = _positive_float( + nd.get("max_upload_gb", cfg.node.max_upload_gb), + cfg.node.max_upload_gb, "max_upload_gb") cfg.node.transcode_incompatible_video = bool( nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video)) cfg.node.hardware_video_encode = bool( diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index ad4a513..3492d03 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -293,18 +293,10 @@ class NodeDaemon: # Apply any roster overrides to node config (panel-edited values # take precedence over node.toml defaults). - from meshbay_node.config import DEFAULT_STUN_SERVERS + from meshbay_node.config import node_settings_defaults nd = self._config.node - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours, - "pair_ttl_hours": nd.pair_ttl_hours, - "device_request_ttl_minutes": nd.device_request_ttl_minutes, - "max_concurrent_streams": nd.max_concurrent_streams, - "transcode_incompatible_video": nd.transcode_incompatible_video, - "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces, - } - effective = await self._roster.node_settings(defaults) + effective = await self._roster.node_settings( + node_settings_defaults(nd)) for k, v in effective.items(): setattr(nd, k, v) @@ -541,6 +533,7 @@ class NodeDaemon: max_concurrent_streams=self._config.node.max_concurrent_streams, max_concurrent_downloads=self._config.node.max_concurrent_downloads, max_concurrent_uploads=self._config.node.max_concurrent_uploads, + max_upload_gb=self._config.node.max_upload_gb, transcode_incompatible_video=self._config.node.transcode_incompatible_video, stun_servers=self._config.node.stun_servers or None, ) @@ -1976,9 +1969,10 @@ def main() -> None: "| chat status|rotate|encrypt-history|prune " "| denylist show|clear " "| stun list|add|remove|reset " - "| transfers show|set|per-member: live transfer " - "slots, the node-wide caps, and how many one " - "member may run at once in a group " + "| transfers show|set|max-size|per-member: live " + "transfer slots, the node-wide caps, the largest " + "single upload, and how many one member may run " + "at once in a group " "| reload: re-read node.toml (hot; systemd or the " "loopback API) | restart-daemon: restart the node " "(systemd unit, the Windows autostart launcher, or the " @@ -1996,13 +1990,14 @@ def main() -> None: "init|rotate for gek; " "list|rm for file; rematch for video; show|clear for " "denylist; list|add|remove|reset for stun; " - "show|set|per-member for transfers; " + "show|set|max-size|per-member for transfers; " "install|remove|start|stop|status for autostart and " "for service") parser.add_argument("target", nargs="?", help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " - "denylist clear; download cap for transfers set") + "denylist clear; download cap for transfers set; " + "size in GB for transfers max-size") parser.add_argument("value", nargs="?", help="the second value where a verb takes two: the " "upload cap for transfers set") @@ -2738,6 +2733,14 @@ def main() -> None: for kind, pool in out.get("pools", {}).items(): print(f" {kind:<9} {pool['in_use']}/{pool['cap']} in use, " f"{pool['queued']} queued (node-wide)") + # From the daemon, not from node.toml: a change made on the Node + # page is live before it is written back, and the number to print + # is the one being enforced. The local file is the fallback rather + # than a literal, so there is no second copy of the default here. + settings = _daemon_api(cfg, "/api/node-settings") + gb = settings.get("max_upload_gb") or cfg.node.max_upload_gb + print(f"\n largest single upload: {gb:g} GB per file " + f"(meshbay-node transfers max-size <GB>)") # Per group, because that is the cap that decides how many one # person runs at once — and it is not the node-wide number. An # operator raising `transfers set 8 8` and still seeing two at a @@ -2787,6 +2790,29 @@ def main() -> None: f"(applied now, and kept in node.toml)") return + if sub == "max-size": + # The largest single file a member may upload here. Not a + # concurrency cap like `set` — it is the one limit that bounds what + # a member writes to the operator's disk, which is why it lives + # beside them rather than under a verb of its own. + if not args.target: + print("usage: meshbay-node transfers max-size <GB>") + sys.exit(1) + try: + gb = float(args.target) + except ValueError: + print("error: the size must be a number of GB (e.g. 8, or 0.5)") + sys.exit(1) + if gb <= 0: + print("error: a ceiling of zero is not 'unlimited'; it would " + "refuse every upload. Make the root read-only instead.") + sys.exit(1) + _daemon_api(cfg, "/api/node-settings", method="PUT", + body={"max_upload_gb": gb}) + print(f"largest single upload: {gb:g} GB per file " + f"(applied now, and kept in node.toml)") + return + if sub == "per-member": # How many transfers ONE member may run at once in this group. Not # the same knob as `set`, which is the machine's total — and the @@ -2820,7 +2846,7 @@ def main() -> None: return print("usage: meshbay-node transfers show|set <downloads> <uploads>|" - "per-member <downloads> <uploads> [--group NAME]") + "max-size <GB>|per-member <downloads> <uploads> [--group NAME]") sys.exit(1) if args.command == "file": diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 98f67c5..ca1b87d 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -40,6 +40,7 @@ from meshbay_common.crypto import ( from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR from meshbay_node.roots import RootError, RootSet, off_disk +from meshbay_node.roster import Roster log = logging.getLogger(__name__) @@ -623,17 +624,11 @@ async def list_groups(state: dict) -> dict: members = await roster.list_members() has_operator = any(m["role"] == "operator" and m["status"] == "active" for m in members) - from meshbay_node.config import DEFAULT_STUN_SERVERS - nd = config.node if config else None - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours if nd else 168, - "pair_ttl_hours": nd.pair_ttl_hours if nd else 24, - "device_request_ttl_minutes": nd.device_request_ttl_minutes if nd else 60, - "max_concurrent_streams": nd.max_concurrent_streams if nd else 8, - "transcode_incompatible_video": nd.transcode_incompatible_video if nd else True, - "stun_servers": nd.stun_servers if nd and nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces if nd else [], - } + from meshbay_node.config import node_settings_defaults + # No config (a test, an unconfigured node) falls back to NodeConfig()'s own + # values rather than to numbers repeated here, which is the copy this used + # to be: it was missing three settings and reported them as null. + defaults = node_settings_defaults(config.node if config else None) if roster: settings = await roster.node_settings(defaults) else: @@ -1288,23 +1283,33 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict: # ── Node settings ──────────────────────────────────────────────────────────── +# What `set_node_settings` accepts, and how each value is validated. A module +# constant so a test can hold its key set against `Roster.node_setting_keys()`: +# this is the third list of the same settings, and the first two had already +# drifted apart once — the reader's defaults covered fewer settings than the +# resolver answered for, which is how node.toml's transfer pools came to be +# parsed and then ignored. The kinds here are the *writer's* validation and +# deliberately not the resolver's coercions. +NODE_SETTING_WRITERS: dict[str, tuple[str, str]] = { + "invite_ttl_hours": ("int", Roster.SETTING_INVITE_TTL), + "pair_ttl_hours": ("int", Roster.SETTING_PAIR_TTL), + "device_request_ttl_minutes": ("int", Roster.SETTING_DEVICE_TTL), + "max_concurrent_streams": ("int", Roster.SETTING_MAX_STREAMS), + "max_concurrent_downloads": ("int", Roster.SETTING_MAX_DOWNLOADS), + "max_concurrent_uploads": ("int", Roster.SETTING_MAX_UPLOADS), + "max_upload_gb": ("size", Roster.SETTING_MAX_UPLOAD_GB), + "transcode_incompatible_video": ("bool", Roster.SETTING_TRANSCODE), + "stun_servers": ("stun_list", Roster.SETTING_STUN_SERVERS), + "ice_interfaces": ("list", Roster.SETTING_ICE_INTERFACES), +} + + async def get_node_settings(state: dict) -> dict: """Return current effective node settings.""" - from meshbay_node.config import DEFAULT_STUN_SERVERS + from meshbay_node.config import node_settings_defaults roster = _roster(state) config = _config(state) - nd = config.node - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours, - "pair_ttl_hours": nd.pair_ttl_hours, - "device_request_ttl_minutes": nd.device_request_ttl_minutes, - "max_concurrent_streams": nd.max_concurrent_streams, - "max_concurrent_downloads": nd.max_concurrent_downloads, - "max_concurrent_uploads": nd.max_concurrent_uploads, - "transcode_incompatible_video": nd.transcode_incompatible_video, - "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces, - } + defaults = node_settings_defaults(config.node) if roster: return await roster.node_settings(defaults) return defaults @@ -1316,17 +1321,7 @@ async def set_node_settings(state: dict, settings: dict) -> dict: nd = config.node conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) - allowed_keys = { - "invite_ttl_hours": ("int", roster.SETTING_INVITE_TTL), - "pair_ttl_hours": ("int", roster.SETTING_PAIR_TTL), - "device_request_ttl_minutes": ("int", roster.SETTING_DEVICE_TTL), - "max_concurrent_streams": ("int", roster.SETTING_MAX_STREAMS), - "max_concurrent_downloads": ("int", roster.SETTING_MAX_DOWNLOADS), - "max_concurrent_uploads": ("int", roster.SETTING_MAX_UPLOADS), - "transcode_incompatible_video": ("bool", roster.SETTING_TRANSCODE), - "stun_servers": ("stun_list", roster.SETTING_STUN_SERVERS), - "ice_interfaces": ("list", roster.SETTING_ICE_INTERFACES), - } + allowed_keys = NODE_SETTING_WRITERS set_by = state.get("node_user_id", "") updated = {} @@ -1344,6 +1339,22 @@ async def set_node_settings(state: dict, settings: dict) -> dict: setattr(nd, key, v) await roster.set_node_setting(setting_key, str(v), set_by) updated[key] = v + elif kind == "size": + # A quantity, not a count: half a gigabyte is a legitimate ceiling + # on a small disk, so this one is not run through the `int` branch + # above, whose floor of 1 would round it to "refuse everything". + # bool before float, as config.py does it: `true` is not 1 GB. + if isinstance(value, bool): + raise OpError(f"{key} must be a number") + try: + fv = float(value) + except (TypeError, ValueError): + raise OpError(f"{key} must be a number") + if fv <= 0: + raise OpError(f"{key} must be greater than zero") + setattr(nd, key, fv) + await roster.set_node_setting(setting_key, repr(fv), set_by) + updated[key] = fv elif kind == "bool": v = bool(value) setattr(nd, key, v) @@ -1383,6 +1394,10 @@ async def set_node_settings(state: dict, settings: dict) -> dict: "max_concurrent_downloads"), max_concurrent_uploads=updated.get( "max_concurrent_uploads")) + if "max_upload_gb" in updated: + webrtc = state.get("webrtc") + if webrtc is not None: + webrtc.set_capacity(max_upload_gb=updated["max_upload_gb"]) if "stun_servers" in updated: webrtc = state.get("webrtc") if webrtc and hasattr(webrtc, '_stun'): diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 064ab93..8c9b3ef 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -983,35 +983,60 @@ class Roster: SETTING_MAX_STREAMS = "max_concurrent_streams" SETTING_MAX_DOWNLOADS = "max_concurrent_downloads" SETTING_MAX_UPLOADS = "max_concurrent_uploads" + SETTING_MAX_UPLOAD_GB = "max_upload_gb" SETTING_TRANSCODE = "transcode_incompatible_video" SETTING_STUN_SERVERS = "stun_servers" SETTING_ICE_INTERFACES = "ice_interfaces" + # Every setting this resolver answers for, and how a stored string becomes + # a value. `node_settings` iterates these two and nothing else, and + # `config.node_settings_defaults` is built from the same names — so the + # defaults dict cannot quietly cover fewer settings than are resolved. + # + # It did. The daemon's dict was written by hand and omitted the two + # transfer pools, so on a node with no panel override they resolved to + # `defaults.get(key)` → None, were assigned back onto the config, and the + # transport skipped them: `node.toml` was parsed, validated, and then + # replaced by the transport's own defaults. A missing key is not an error + # anywhere along that path — it is a setting that stops working in silence, + # and invisible unless the operator picked a value other than the default. + NODE_SETTING_SCALARS: tuple[tuple[str, str, str], ...] = ( + ("invite_ttl_hours", SETTING_INVITE_TTL, "int"), + ("pair_ttl_hours", SETTING_PAIR_TTL, "int"), + ("device_request_ttl_minutes", SETTING_DEVICE_TTL, "int"), + ("max_concurrent_streams", SETTING_MAX_STREAMS, "int"), + ("max_concurrent_downloads", SETTING_MAX_DOWNLOADS, "int"), + ("max_concurrent_uploads", SETTING_MAX_UPLOADS, "int"), + ("transcode_incompatible_video", SETTING_TRANSCODE, "bool"), + ("max_upload_gb", SETTING_MAX_UPLOAD_GB, "float"), + ) + NODE_SETTING_LISTS: tuple[tuple[str, str], ...] = ( + ("stun_servers", SETTING_STUN_SERVERS), + ("ice_interfaces", SETTING_ICE_INTERFACES), + ) + + @classmethod + def node_setting_keys(cls) -> frozenset[str]: + """Every key `node_settings` returns — what a defaults dict must cover.""" + return frozenset([k for k, _, _ in cls.NODE_SETTING_SCALARS] + + [k for k, _ in cls.NODE_SETTING_LISTS]) + async def node_settings(self, defaults: dict) -> dict: """Current effective settings: roster override if present, else config default.""" import json as _json result = {} - for key, setting in [ - ("invite_ttl_hours", self.SETTING_INVITE_TTL), - ("pair_ttl_hours", self.SETTING_PAIR_TTL), - ("device_request_ttl_minutes", self.SETTING_DEVICE_TTL), - ("max_concurrent_streams", self.SETTING_MAX_STREAMS), - ("max_concurrent_downloads", self.SETTING_MAX_DOWNLOADS), - ("max_concurrent_uploads", self.SETTING_MAX_UPLOADS), - ("transcode_incompatible_video", self.SETTING_TRANSCODE), - ]: + for key, setting, kind in self.NODE_SETTING_SCALARS: stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) if stored is not None: - if key == "transcode_incompatible_video": + if kind == "bool": result[key] = stored != "0" + elif kind == "float": + result[key] = float(stored) else: result[key] = int(stored) else: result[key] = defaults.get(key) - for list_key, setting in [ - ("stun_servers", self.SETTING_STUN_SERVERS), - ("ice_interfaces", self.SETTING_ICE_INTERFACES), - ]: + for list_key, setting in self.NODE_SETTING_LISTS: stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) if stored is not None: try: diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index ba8a3e4..8a5bbff 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -256,7 +256,13 @@ _CHAT_RATE_MAX_TRACKED = 1000 # a name the client chose, overwriting whatever was already there — which both violated # node sovereignty and defeated the delete authorization (overwrite a file, become its # recorded uploader, then delete it legitimately). -MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per file +# The ceiling is the operator's to set (`max_upload_gb` in node.toml, the Node +# page and `meshbay-node transfers max-size`) because it is their disk that +# fills: this is only the default a node starts from when they have said +# nothing. It is read from the transport context on every chunk, so a change +# applies to an upload already in flight. +MAX_UPLOAD_BYTES = 8 * 1024 * 1024 * 1024 # 8 GB per file +GB_BYTES = 1024 * 1024 * 1024 # What the `tr` on a chunk request turned out to be (see `_lease_of`). LEASE_GRANTED = "granted" @@ -3552,7 +3558,21 @@ class WebRTCPeerSession: "queued": len(progress.queued), } - # ── Transfer slots ─────────────────────────────────────────────────────── + # ── Upload ceiling and transfer slots ──────────────────────────────────── + + def _max_upload_bytes(self) -> int: + """The per-file upload ceiling this node is running with, in bytes. + + Read from the transport context rather than captured once, for the same + reason the transfer pools are refreshed there: the operator can change + it from the Node page or the CLI while an upload is running, and a + ceiling that only applies after a restart is not the one they were + shown. `None` means they have said nothing and the default stands. + """ + gb = self._ctx.get("max_upload_gb") + if not gb: + return MAX_UPLOAD_BYTES + return max(1, int(float(gb) * GB_BYTES)) def _slots(self) -> "TransferSlots": """The node's transfer pools, shared across every peer and every group. @@ -5597,7 +5617,7 @@ class WebRTCPeerSession: _refuse("Unexpected chunk index", "bad_chunk_index") return - if state.bytes + len(chunk_bytes) > MAX_UPLOAD_BYTES: + if state.bytes + len(chunk_bytes) > self._max_upload_bytes(): uploads.drop(user_id, rel_dir, filename) await off_disk(roots, tmp_path.unlink, True) _refuse("Upload exceeds size limit", "too_large") @@ -6991,6 +7011,7 @@ class WebRTCTransport: max_concurrent_streams: int | None = None, max_concurrent_downloads: int | None = None, max_concurrent_uploads: int | None = None, + max_upload_gb: float | None = None, transcode_incompatible_video: bool = True, ): self._ctx: dict[str, Any] = { @@ -7007,6 +7028,9 @@ class WebRTCTransport: # the operator said nothing and transfers.py's defaults apply. "max_concurrent_downloads": max_concurrent_downloads, "max_concurrent_uploads": max_concurrent_uploads, + # The per-file upload ceiling, in GB. None means the operator said + # nothing and MAX_UPLOAD_BYTES stands. + "max_upload_gb": max_upload_gb, # Operator opt-out (node.toml) for the HEVC-etc. transcode # fallback in _stream_video_inner — real CPU cost, unlike copy. "transcode_incompatible_video": transcode_incompatible_video, @@ -7022,7 +7046,8 @@ class WebRTCTransport: def set_capacity(self, *, max_concurrent_streams: int | None = None, max_concurrent_downloads: int | None = None, - max_concurrent_uploads: int | None = None) -> dict: + max_concurrent_uploads: int | None = None, + max_upload_gb: float | None = None) -> dict: """Resize a live pool without restarting the daemon. `ops.set_node_settings` used to do this by assigning @@ -7089,6 +7114,14 @@ class WebRTCTransport: # prevent. for lease in granted: self._notify_granted(lease) + + if max_upload_gb is not None: + gb = float(max_upload_gb) + if gb <= 0: + raise ValueError("max_upload_gb must be greater than zero") + self._ctx["max_upload_gb"] = gb + changed["max_upload_gb"] = gb + log.info("upload: per-file ceiling now %g GB", gb) return changed def _notify_granted(self, lease) -> None: |