summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/daemon.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py173
1 files changed, 172 insertions, 1 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index e270b6c..371c2f0 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -32,6 +32,7 @@ import logging
import os
import signal
import sys
+import time
from pathlib import Path
import uvicorn
@@ -51,6 +52,7 @@ from meshbay_node.indexer.enrich_audio import AudioEnricher
from meshbay_node.indexer.enrich_photo import PhotoEnricher
from meshbay_node.media_cache import MediaCache
from meshbay_node.tmdb import TmdbClient
+from meshbay_node import uploads as uploads_mod
from meshbay_node.musicbrainz import MusicBrainzClient
from meshbay_node.keystore import create_keystore, load_keystore, load_or_create_keystore
from meshbay_node.platform import chmod_private, config_dir, data_dir, state_dir
@@ -249,6 +251,8 @@ class NodeDaemon:
self._tasks.append(asyncio.create_task(ui_server.serve()))
log.info("Control API on 127.0.0.1:%d", self._config.node.ui_port)
+ self._tasks.append(asyncio.create_task(self._reap_partial_uploads()))
+
# 3. Hub connection (Ed25519 auth — retries until node key is linked)
hub_cfg = HubConfig(
hub_url=self._config.hub.url,
@@ -406,6 +410,12 @@ class NodeDaemon:
# which the RootSet above already carries.)
"enabled_apps": await self._roster.enabled_apps(
group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS),
+ # How many transfers one member may run at once here. Empty
+ # means the operator has not said, and the node's default
+ # applies — never "unlimited" (transfers.member_cap).
+ "transfer_limits": (
+ await self._roster.transfer_limits(group_cfg.id)
+ if self._roster else {}),
# Which folder(s) inside the shared roots each app works
# over. One shape for every app (roster.py's
# app_directories) — an empty list means nothing has been
@@ -511,6 +521,8 @@ class NodeDaemon:
groups=groups_ctx,
denylist=denylist,
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,
transcode_incompatible_video=self._config.node.transcode_incompatible_video,
stun_servers=self._config.node.stun_servers or None,
)
@@ -874,6 +886,9 @@ class NodeDaemon:
"enabled_apps": (
await self._roster.enabled_apps(group_cfg.id)
if self._roster else list(Roster.DEFAULT_APPS)),
+ "transfer_limits": (
+ await self._roster.transfer_limits(group_cfg.id)
+ if self._roster else {}),
**(await self._app_directories_ctx(group_cfg.id)),
"chat_link_preview": (
await self._roster.chat_link_preview(group_cfg.id)
@@ -1046,6 +1061,60 @@ class NodeDaemon:
group_id[:8], e)
return 0
+ async def _reap_partial_uploads(self, interval: float = 3600.0,
+ first_delay: float = 60.0) -> None:
+ """
+ Delete `.part` files that no upload will ever finish.
+
+ An upload interrupted for good leaves its partial file behind, and
+ nothing else ever looks at it: `.part` is not an index entry, so it is
+ invisible to every group member and to the operator's own file list. One
+ abandoned film is a gigabyte of their disk, kept for ever.
+
+ Two conditions, both required, and `uploads.orphaned_parts` is where
+ they are stated and tested. What this adds is the walk and the deletion,
+ and one rule of its own: it runs a minute after start rather than at
+ once, so a client reconnecting to finish an upload that outlived a node
+ restart is not raced by the janitor that would have deleted it — the age
+ threshold makes that impossible in practice, and doing it anyway costs a
+ minute.
+
+ `interval` and `first_delay` are parameters so a test can drive this
+ without waiting an hour.
+ """
+ await asyncio.sleep(first_delay)
+ while True:
+ try:
+ self._reap_once()
+ except Exception as exc: # never let the janitor kill the node
+ log.warning("Reaping partial uploads failed: %s", exc)
+ await asyncio.sleep(interval)
+
+ def _reap_once(self, now: float | None = None) -> int:
+ """One pass over every group. Returns how many files were deleted."""
+ groups = (self._webrtc._ctx.get("groups") or {}) if self._webrtc else {}
+ when = time.time() if now is None else now
+ deleted = 0
+ for gid, ctx in groups.items():
+ roots = ctx.get("roots")
+ if roots is None:
+ continue
+ store = ctx.get("partial_uploads")
+ live = store.live_paths() if store is not None else set()
+ for path in uploads_mod.orphaned_parts(
+ uploads_mod.find_parts(roots.roots), live, when):
+ try:
+ size = path.stat().st_size
+ path.unlink()
+ except OSError as exc:
+ log.warning("Could not remove abandoned upload %s: %s",
+ path.name, exc)
+ continue
+ deleted += 1
+ log.info("Removed abandoned upload %s (%d bytes, group %s)",
+ path.name, size, gid[:8])
+ return deleted
+
async def _progress_pusher(self, indexer: DirectoryIndexer,
interval: float = 2.0) -> None:
"""
@@ -1799,6 +1868,7 @@ def main() -> None:
choices=["init", "reset", "status", "gek-init",
"gek", "operator", "member", "group", "root",
"file", "video", "chat", "denylist", "stun",
+ "transfers",
"reload",
"restart-daemon", "autostart", "service",
"calibrate-argon2"],
@@ -1814,6 +1884,9 @@ 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 "
"| reload: re-read node.toml (hot; systemd or the "
"loopback API) | restart-daemon: restart the node "
"(systemd unit, the Windows autostart launcher, or the "
@@ -1831,12 +1904,16 @@ 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; "
"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")
+ "denylist clear; download cap for transfers set")
+ parser.add_argument("value", nargs="?",
+ help="the second value where a verb takes two: the "
+ "upload cap for transfers set")
parser.add_argument("--hub-url", default=None,
help="hub URL, for init (e.g. https://meshbay.org)")
parser.add_argument("--username", default=None,
@@ -2560,6 +2637,100 @@ def main() -> None:
print("usage: meshbay-node stun list|add|remove|reset [url]")
sys.exit(1)
+ if args.command == "transfers":
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ sub = args.subcommand or "show"
+
+ if sub == "show":
+ out = _daemon_api(cfg, "/api/transfers")
+ 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)")
+ # 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
+ # time is looking at this line, which used to print the node's
+ # default and say nothing about where it came from.
+ groups = out.get("groups") or []
+ if groups:
+ print("\n per member, per group "
+ "(meshbay-node transfers per-member <dl> <ul> --group X):")
+ for g in groups:
+ how = "set" if g["set"] else "default"
+ print(f" {g['name']:<20} {g['download']} download(s), "
+ f"{g['upload']} upload(s) [{how}]")
+ leases = out.get("leases", [])
+ if not leases:
+ print("\n nothing transferring")
+ return
+ print(f"\n {'transfer':<14}{'kind':<10}{'state':<9}"
+ f"{'user':<12}{'bytes':>12}")
+ for x in leases:
+ where = f" (#{x['ahead'] + 1} in queue)" if x["state"] == "queued" else ""
+ print(f" {x['tr']:<14}{x['kind']:<10}{x['state']:<9}"
+ f"{x['user_id'][:10]:<12}{x['bytes']:>12}{where}")
+ return
+
+ if sub == "set":
+ # `transfers set 4 2` — downloads, then uploads. Node-wide; the
+ # per-member cap is a group's setting and is signed, so it is not
+ # settable from here (see `ops.set_transfer_limits`).
+ values = [v for v in (args.target, args.value) if v]
+ if len(values) != 2:
+ print("usage: meshbay-node transfers set <downloads> <uploads>")
+ sys.exit(1)
+ try:
+ downloads, uploads = int(values[0]), int(values[1])
+ except ValueError:
+ print("error: both values must be whole numbers")
+ sys.exit(1)
+ if downloads < 1 or uploads < 1:
+ print("error: a cap below 1 is not 'unlimited'; it would stop "
+ "every transfer. Revoke the member instead.")
+ sys.exit(1)
+ out = _daemon_api(cfg, "/api/node-settings", method="PUT",
+ body={"max_concurrent_downloads": downloads,
+ "max_concurrent_uploads": uploads})
+ print(f"downloads: {downloads}, uploads: {uploads} "
+ 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
+ # reason "I set 8 8 and still only get two" is the commonest
+ # confusion here: per-member is checked first, by design.
+ values = [v for v in (args.target, args.value) if v]
+ if len(values) != 2:
+ print("usage: meshbay-node transfers per-member <downloads> "
+ "<uploads> [--group NAME]")
+ sys.exit(1)
+ try:
+ downloads, uploads = int(values[0]), int(values[1])
+ except ValueError:
+ print("error: both values must be whole numbers")
+ sys.exit(1)
+ if downloads < 1 or uploads < 1:
+ print("error: a cap below 1 is not 'unlimited'; it would stop "
+ "every transfer for that member. Revoke them instead.")
+ sys.exit(1)
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(cfg, f"/api/groups/{group_id}/transfer-limits",
+ method="PUT",
+ body={"downloads": downloads, "uploads": uploads})
+ got = out.get("limits", {})
+ started = out.get("started") or []
+ print(f"each member of this group may now run "
+ f"{got.get('download')} download(s) and "
+ f"{got.get('upload')} upload(s) at once")
+ if started:
+ print(f"{len(started)} waiting transfer(s) started at once")
+ return
+
+ print("usage: meshbay-node transfers show|set <downloads> <uploads>|"
+ "per-member <downloads> <uploads> [--group NAME]")
+ sys.exit(1)
+
if args.command == "file":
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
sub = args.subcommand or "list"