From 14911aae21ec4975f7a805b91e3c4867cb41974b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 12 Oct 2026 00:17:32 +0200 Subject: feat: let the operator pause and resume a root's indexing (MNP 6.2) A whole-root walk now checks its root before every file: removed or ejected, it stops instead of reading the drive to the end; paused, it finishes from the hash cache alone. A paused root is not watched, not swept by reconcile and survives a restart (roster), its indexed files stay served. Resume runs the reconcile sweep over the root, so entries and their enrichment are kept. Signed root_pause / root_resume over MNP, loopback routes, CLI `root pause|resume`, a pause button on the indexing dock and pause/resume in the roots table of Group settings. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-node/tests/golden/cli.json | 4 +- packages/meshbay-node/tests/golden/dispatch.json | 982 ++++++++++++++++++--- .../meshbay-node/tests/test_root_availability.py | 2 +- .../tests/test_root_paths_are_operator_only.py | 2 +- packages/meshbay-node/tests/test_root_pause.py | 313 +++++++ packages/meshbay-node/tests/test_roots.py | 13 +- 6 files changed, 1196 insertions(+), 120 deletions(-) create mode 100644 packages/meshbay-node/tests/test_root_pause.py (limited to 'packages/meshbay-node/tests') diff --git a/packages/meshbay-node/tests/golden/cli.json b/packages/meshbay-node/tests/golden/cli.json index 48d2f74..d069510 100644 --- a/packages/meshbay-node/tests/golden/cli.json +++ b/packages/meshbay-node/tests/golden/cli.json @@ -4,7 +4,7 @@ "asked": [], "exit": 0, "stderr": "", - "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--open] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --open group add: anyone the hub lists the group to may join (default: by\n invitation only)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n", + "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--open] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug|pause|resume | gek init|rotate | file\n list|rm | video rematch: re-resolve TMDB matches for a group's videos |\n chat status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug|pause|resume for root;\n init|rotate for gek; list|rm for file; rematch for video; show|clear for\n denylist; list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --open group add: anyone the hub lists the group to may join (default: by\n invitation only)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n", "systemctl": [] }, "autostart no-such-sub": { @@ -550,7 +550,7 @@ "asked": [], "exit": 1, "stderr": "", - "stdout": "usage: meshbay-node root list|add|remove|set|eject|plug [name] [--group NAME]\n", + "stdout": "usage: meshbay-node root list|add|remove|set|eject|plug|pause|resume [name] [--group NAME]\n", "systemctl": [] }, "root plug media": { diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json index a39c57d..384c38f 100644 --- a/packages/meshbay-node/tests/golden/dispatch.json +++ b/packages/meshbay-node/tests/golden/dispatch.json @@ -144,6 +144,14 @@ "_admin_exec_root_eject" ] }, + "root_pause": { + "audit": [], + "log": [], + "sent": [], + "spawned": [ + "_admin_exec_root_pause" + ] + }, "root_plug": { "audit": [], "log": [], @@ -160,6 +168,14 @@ "_admin_exec_root_remove" ] }, + "root_resume": { + "audit": [], + "log": [], + "sent": [], + "spawned": [ + "_admin_exec_root_resume" + ] + }, "search_listed": { "audit": [], "log": [], @@ -2020,7 +2036,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2343,7 +2359,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2362,7 +2378,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2381,7 +2397,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2400,7 +2416,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2670,7 +2686,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2684,7 +2700,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2698,7 +2714,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2712,7 +2728,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2726,7 +2742,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2740,7 +2756,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2754,7 +2770,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -2768,7 +2784,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -3983,7 +3999,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -4002,7 +4018,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -4021,7 +4037,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -4040,7 +4056,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -10159,7 +10175,7 @@ "subject": "link:gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -12258,7 +12274,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -12277,7 +12293,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -12296,7 +12312,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13397,7 +13413,7 @@ "req_id": 4242, "token": null, "type": "pong", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13412,7 +13428,7 @@ "x" ], "type": "pong", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13425,7 +13441,7 @@ "req_id": 4242, "token": 7, "type": "pong", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13438,7 +13454,7 @@ "req_id": 4242, "token": "x", "type": "pong", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13451,7 +13467,7 @@ "req_id": 4242, "token": null, "type": "pong", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13466,7 +13482,7 @@ "x" ], "type": "pong", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13479,7 +13495,7 @@ "req_id": 4242, "token": 7, "type": "pong", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13492,7 +13508,7 @@ "req_id": 4242, "token": "x", "type": "pong", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13827,7 +13843,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13846,7 +13862,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -13865,7 +13881,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -14030,7 +14046,7 @@ "sent": [], "spawned": [] }, - "root_plug | challenged | bare": { + "root_pause | challenged | bare": { "audit": [], "log": [], "sent": [ @@ -14042,7 +14058,7 @@ ], "spawned": [] }, - "root_plug | challenged | lists": { + "root_pause | challenged | lists": { "audit": [], "log": [], "sent": [ @@ -14054,7 +14070,7 @@ ], "spawned": [] }, - "root_plug | challenged | numbers": { + "root_pause | challenged | numbers": { "audit": [], "log": [], "sent": [ @@ -14066,7 +14082,7 @@ ], "spawned": [] }, - "root_plug | challenged | strings": { + "root_pause | challenged | strings": { "audit": [], "log": [], "sent": [ @@ -14078,7 +14094,7 @@ ], "spawned": [] }, - "root_plug | fresh | bare": { + "root_pause | fresh | bare": { "audit": [], "log": [], "sent": [ @@ -14090,7 +14106,7 @@ ], "spawned": [] }, - "root_plug | fresh | lists": { + "root_pause | fresh | lists": { "audit": [], "log": [], "sent": [ @@ -14102,7 +14118,7 @@ ], "spawned": [] }, - "root_plug | fresh | numbers": { + "root_pause | fresh | numbers": { "audit": [], "log": [], "sent": [ @@ -14114,7 +14130,7 @@ ], "spawned": [] }, - "root_plug | fresh | strings": { + "root_pause | fresh | strings": { "audit": [], "log": [], "sent": [ @@ -14126,7 +14142,7 @@ ], "spawned": [] }, - "root_plug | member | bare": { + "root_pause | member | bare": { "audit": [], "log": [], "sent": [ @@ -14138,7 +14154,7 @@ ], "spawned": [] }, - "root_plug | member | lists": { + "root_pause | member | lists": { "audit": [], "log": [], "sent": [ @@ -14150,7 +14166,7 @@ ], "spawned": [] }, - "root_plug | member | numbers": { + "root_pause | member | numbers": { "audit": [], "log": [], "sent": [ @@ -14162,7 +14178,7 @@ ], "spawned": [] }, - "root_plug | member | strings": { + "root_pause | member | strings": { "audit": [], "log": [], "sent": [ @@ -14174,7 +14190,7 @@ ], "spawned": [] }, - "root_plug | operator | bare": { + "root_pause | operator | bare": { "audit": [], "log": [], "sent": [ @@ -14186,7 +14202,7 @@ ], "spawned": [] }, - "root_plug | operator | lists": { + "root_pause | operator | lists": { "audit": [], "log": [], "sent": [ @@ -14194,18 +14210,18 @@ "group_id": "['x']", "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", "nonce": "", - "op": "root_plug", + "op": "root_pause", "op_id": "", "req_id": 4242, "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] }, - "root_plug | operator | numbers": { + "root_pause | operator | numbers": { "audit": [], "log": [], "sent": [ @@ -14213,18 +14229,18 @@ "group_id": "7", "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", "nonce": "", - "op": "root_plug", + "op": "root_pause", "op_id": "", "req_id": 4242, "subject": "7", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] }, - "root_plug | operator | strings": { + "root_pause | operator | strings": { "audit": [], "log": [], "sent": [ @@ -14232,18 +14248,18 @@ "group_id": "x", "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", "nonce": "", - "op": "root_plug", + "op": "root_pause", "op_id": "", "req_id": 4242, "subject": "x", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] }, - "root_plug_ack | challenged | bare": { + "root_pause_ack | challenged | bare": { "audit": [], "log": [], "sent": [ @@ -14255,7 +14271,7 @@ ], "spawned": [] }, - "root_plug_ack | challenged | lists": { + "root_pause_ack | challenged | lists": { "audit": [], "log": [], "sent": [ @@ -14267,7 +14283,7 @@ ], "spawned": [] }, - "root_plug_ack | challenged | numbers": { + "root_pause_ack | challenged | numbers": { "audit": [], "log": [], "sent": [ @@ -14279,7 +14295,7 @@ ], "spawned": [] }, - "root_plug_ack | challenged | strings": { + "root_pause_ack | challenged | strings": { "audit": [], "log": [], "sent": [ @@ -14291,7 +14307,7 @@ ], "spawned": [] }, - "root_plug_ack | fresh | bare": { + "root_pause_ack | fresh | bare": { "audit": [], "log": [], "sent": [ @@ -14303,7 +14319,7 @@ ], "spawned": [] }, - "root_plug_ack | fresh | lists": { + "root_pause_ack | fresh | lists": { "audit": [], "log": [], "sent": [ @@ -14315,7 +14331,7 @@ ], "spawned": [] }, - "root_plug_ack | fresh | numbers": { + "root_pause_ack | fresh | numbers": { "audit": [], "log": [], "sent": [ @@ -14327,7 +14343,7 @@ ], "spawned": [] }, - "root_plug_ack | fresh | strings": { + "root_pause_ack | fresh | strings": { "audit": [], "log": [], "sent": [ @@ -14339,7 +14355,7 @@ ], "spawned": [] }, - "root_plug_ack | member | bare": { + "root_pause_ack | member | bare": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14347,7 +14363,7 @@ "sent": [], "spawned": [] }, - "root_plug_ack | member | lists": { + "root_pause_ack | member | lists": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14355,7 +14371,7 @@ "sent": [], "spawned": [] }, - "root_plug_ack | member | numbers": { + "root_pause_ack | member | numbers": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14363,7 +14379,7 @@ "sent": [], "spawned": [] }, - "root_plug_ack | member | strings": { + "root_pause_ack | member | strings": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14371,7 +14387,7 @@ "sent": [], "spawned": [] }, - "root_plug_ack | operator | bare": { + "root_pause_ack | operator | bare": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14379,7 +14395,7 @@ "sent": [], "spawned": [] }, - "root_plug_ack | operator | lists": { + "root_pause_ack | operator | lists": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14387,7 +14403,7 @@ "sent": [], "spawned": [] }, - "root_plug_ack | operator | numbers": { + "root_pause_ack | operator | numbers": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14395,7 +14411,7 @@ "sent": [], "spawned": [] }, - "root_plug_ack | operator | strings": { + "root_pause_ack | operator | strings": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14403,7 +14419,7 @@ "sent": [], "spawned": [] }, - "root_remove | challenged | bare": { + "root_plug | challenged | bare": { "audit": [], "log": [], "sent": [ @@ -14415,7 +14431,7 @@ ], "spawned": [] }, - "root_remove | challenged | lists": { + "root_plug | challenged | lists": { "audit": [], "log": [], "sent": [ @@ -14427,7 +14443,7 @@ ], "spawned": [] }, - "root_remove | challenged | numbers": { + "root_plug | challenged | numbers": { "audit": [], "log": [], "sent": [ @@ -14439,7 +14455,7 @@ ], "spawned": [] }, - "root_remove | challenged | strings": { + "root_plug | challenged | strings": { "audit": [], "log": [], "sent": [ @@ -14451,7 +14467,7 @@ ], "spawned": [] }, - "root_remove | fresh | bare": { + "root_plug | fresh | bare": { "audit": [], "log": [], "sent": [ @@ -14463,7 +14479,7 @@ ], "spawned": [] }, - "root_remove | fresh | lists": { + "root_plug | fresh | lists": { "audit": [], "log": [], "sent": [ @@ -14475,7 +14491,7 @@ ], "spawned": [] }, - "root_remove | fresh | numbers": { + "root_plug | fresh | numbers": { "audit": [], "log": [], "sent": [ @@ -14487,7 +14503,7 @@ ], "spawned": [] }, - "root_remove | fresh | strings": { + "root_plug | fresh | strings": { "audit": [], "log": [], "sent": [ @@ -14499,7 +14515,7 @@ ], "spawned": [] }, - "root_remove | member | bare": { + "root_plug | member | bare": { "audit": [], "log": [], "sent": [ @@ -14511,7 +14527,7 @@ ], "spawned": [] }, - "root_remove | member | lists": { + "root_plug | member | lists": { "audit": [], "log": [], "sent": [ @@ -14523,7 +14539,7 @@ ], "spawned": [] }, - "root_remove | member | numbers": { + "root_plug | member | numbers": { "audit": [], "log": [], "sent": [ @@ -14535,7 +14551,7 @@ ], "spawned": [] }, - "root_remove | member | strings": { + "root_plug | member | strings": { "audit": [], "log": [], "sent": [ @@ -14547,7 +14563,7 @@ ], "spawned": [] }, - "root_remove | operator | bare": { + "root_plug | operator | bare": { "audit": [], "log": [], "sent": [ @@ -14559,7 +14575,7 @@ ], "spawned": [] }, - "root_remove | operator | lists": { + "root_plug | operator | lists": { "audit": [], "log": [], "sent": [ @@ -14567,18 +14583,18 @@ "group_id": "['x']", "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", "nonce": "", - "op": "root_remove", + "op": "root_plug", "op_id": "", "req_id": 4242, "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] }, - "root_remove | operator | numbers": { + "root_plug | operator | numbers": { "audit": [], "log": [], "sent": [ @@ -14586,18 +14602,18 @@ "group_id": "7", "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", "nonce": "", - "op": "root_remove", + "op": "root_plug", "op_id": "", "req_id": 4242, "subject": "7", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] }, - "root_remove | operator | strings": { + "root_plug | operator | strings": { "audit": [], "log": [], "sent": [ @@ -14605,18 +14621,18 @@ "group_id": "x", "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", "nonce": "", - "op": "root_remove", + "op": "root_plug", "op_id": "", "req_id": 4242, "subject": "x", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] }, - "root_remove_ack | challenged | bare": { + "root_plug_ack | challenged | bare": { "audit": [], "log": [], "sent": [ @@ -14628,7 +14644,7 @@ ], "spawned": [] }, - "root_remove_ack | challenged | lists": { + "root_plug_ack | challenged | lists": { "audit": [], "log": [], "sent": [ @@ -14640,7 +14656,7 @@ ], "spawned": [] }, - "root_remove_ack | challenged | numbers": { + "root_plug_ack | challenged | numbers": { "audit": [], "log": [], "sent": [ @@ -14652,7 +14668,7 @@ ], "spawned": [] }, - "root_remove_ack | challenged | strings": { + "root_plug_ack | challenged | strings": { "audit": [], "log": [], "sent": [ @@ -14664,7 +14680,7 @@ ], "spawned": [] }, - "root_remove_ack | fresh | bare": { + "root_plug_ack | fresh | bare": { "audit": [], "log": [], "sent": [ @@ -14676,7 +14692,7 @@ ], "spawned": [] }, - "root_remove_ack | fresh | lists": { + "root_plug_ack | fresh | lists": { "audit": [], "log": [], "sent": [ @@ -14688,7 +14704,7 @@ ], "spawned": [] }, - "root_remove_ack | fresh | numbers": { + "root_plug_ack | fresh | numbers": { "audit": [], "log": [], "sent": [ @@ -14700,7 +14716,7 @@ ], "spawned": [] }, - "root_remove_ack | fresh | strings": { + "root_plug_ack | fresh | strings": { "audit": [], "log": [], "sent": [ @@ -14712,7 +14728,7 @@ ], "spawned": [] }, - "root_remove_ack | member | bare": { + "root_plug_ack | member | bare": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14720,7 +14736,7 @@ "sent": [], "spawned": [] }, - "root_remove_ack | member | lists": { + "root_plug_ack | member | lists": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14728,7 +14744,7 @@ "sent": [], "spawned": [] }, - "root_remove_ack | member | numbers": { + "root_plug_ack | member | numbers": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14736,7 +14752,7 @@ "sent": [], "spawned": [] }, - "root_remove_ack | member | strings": { + "root_plug_ack | member | strings": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14744,7 +14760,7 @@ "sent": [], "spawned": [] }, - "root_remove_ack | operator | bare": { + "root_plug_ack | operator | bare": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14752,7 +14768,7 @@ "sent": [], "spawned": [] }, - "root_remove_ack | operator | lists": { + "root_plug_ack | operator | lists": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14760,7 +14776,7 @@ "sent": [], "spawned": [] }, - "root_remove_ack | operator | numbers": { + "root_plug_ack | operator | numbers": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -14768,7 +14784,753 @@ "sent": [], "spawned": [] }, - "root_remove_ack | operator | strings": { + "root_plug_ack | operator | strings": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_remove | challenged | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | challenged | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | challenged | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | challenged | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | fresh | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | fresh | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | fresh | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | fresh | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | member | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Missing group_id or root_name", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | member | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | member | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | member | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | operator | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Missing group_id or root_name", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove | operator | lists": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "['x']", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "", + "op": "root_remove", + "op_id": "", + "req_id": 4242, + "subject": "['x']", + "ts": "", + "type": "admin_challenge", + "v": "6.2" + } + ], + "spawned": [] + }, + "root_remove | operator | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "7", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "", + "op": "root_remove", + "op_id": "", + "req_id": 4242, + "subject": "7", + "ts": "", + "type": "admin_challenge", + "v": "6.2" + } + ], + "spawned": [] + }, + "root_remove | operator | strings": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "x", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "", + "op": "root_remove", + "op_id": "", + "req_id": 4242, + "subject": "x", + "ts": "", + "type": "admin_challenge", + "v": "6.2" + } + ], + "spawned": [] + }, + "root_remove_ack | challenged | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove_ack | challenged | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove_ack | challenged | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove_ack | challenged | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove_ack | fresh | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove_ack | fresh | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove_ack | fresh | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove_ack | fresh | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_remove_ack | member | bare": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_remove_ack | member | lists": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_remove_ack | member | numbers": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_remove_ack | member | strings": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_remove_ack | operator | bare": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_remove_ack | operator | lists": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_remove_ack | operator | numbers": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_remove_ack | operator | strings": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_resume | challenged | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | challenged | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | challenged | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | challenged | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | fresh | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | fresh | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | fresh | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | fresh | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | member | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Missing group_id or root_name", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | member | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | member | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | member | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | operator | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Missing group_id or root_name", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume | operator | lists": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "['x']", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "", + "op": "root_resume", + "op_id": "", + "req_id": 4242, + "subject": "['x']", + "ts": "", + "type": "admin_challenge", + "v": "6.2" + } + ], + "spawned": [] + }, + "root_resume | operator | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "7", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "", + "op": "root_resume", + "op_id": "", + "req_id": 4242, + "subject": "7", + "ts": "", + "type": "admin_challenge", + "v": "6.2" + } + ], + "spawned": [] + }, + "root_resume | operator | strings": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "x", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "", + "op": "root_resume", + "op_id": "", + "req_id": 4242, + "subject": "x", + "ts": "", + "type": "admin_challenge", + "v": "6.2" + } + ], + "spawned": [] + }, + "root_resume_ack | challenged | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume_ack | challenged | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume_ack | challenged | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume_ack | challenged | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume_ack | fresh | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume_ack | fresh | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume_ack | fresh | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume_ack | fresh | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "root_resume_ack | member | bare": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_resume_ack | member | lists": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_resume_ack | member | numbers": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_resume_ack | member | strings": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_resume_ack | operator | bare": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_resume_ack | operator | lists": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_resume_ack | operator | numbers": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "root_resume_ack | operator | strings": { "audit": [], "log": [ "WARNING Unknown MNP message type on DataChannel: %s" @@ -17238,7 +18000,7 @@ "subject": "{\"language\":null,\"token\":null}", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] @@ -17281,7 +18043,7 @@ "subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}", "ts": "", "type": "admin_challenge", - "v": "6.1" + "v": "6.2" } ], "spawned": [] diff --git a/packages/meshbay-node/tests/test_root_availability.py b/packages/meshbay-node/tests/test_root_availability.py index 7cc890f..d31e11b 100644 --- a/packages/meshbay-node/tests/test_root_availability.py +++ b/packages/meshbay-node/tests/test_root_availability.py @@ -120,7 +120,7 @@ async def test_members_are_told_which_roots_are_unavailable(tmp_path): idx = await _indexer(_roots(films)) assert idx.index.roots == [ {"name": "Films", "kind": "generic", "available": True, - "writable": True, "removable": False, "ejected": False}] + "writable": True, "removable": False, "ejected": False, "paused": False}] (films / "a.mkv").unlink() films.rmdir() diff --git a/packages/meshbay-node/tests/test_root_paths_are_operator_only.py b/packages/meshbay-node/tests/test_root_paths_are_operator_only.py index 646c7b4..edbc62f 100644 --- a/packages/meshbay-node/tests/test_root_paths_are_operator_only.py +++ b/packages/meshbay-node/tests/test_root_paths_are_operator_only.py @@ -102,7 +102,7 @@ def test_the_cli_only_reads_fields_the_payload_carries(monkeypatch, tmp_path, ca path = Path("/tmp/x") name = "x" kind = "generic" - writable = removable = ejected = False + writable = removable = ejected = paused = False available = True offered = RootSet(roots=[_Any()]).describe(with_paths=True)[0] diff --git a/packages/meshbay-node/tests/test_root_pause.py b/packages/meshbay-node/tests/test_root_pause.py new file mode 100644 index 0000000..d9a4fcb --- /dev/null +++ b/packages/meshbay-node/tests/test_root_pause.py @@ -0,0 +1,313 @@ +""" +Pausing a root's indexing, and the walk that has to notice it. + +A scan of a large drive runs for hours, and nothing could stop one: the hashing +loop never looked at the root again once it had the file list. Removing the +directory let it read the whole drive anyway and threw the result away at the +end; ejecting it went on reading a drive the operator was about to unplug. + +A pause has to outlast the walk it stops. Reconcile indexes every file the index +lacks, so a walk that was merely cancelled was redone at the next tick; the flag +is what reconcile, the watcher and a restart all read. And it keeps what was +indexed: the index is rebuilt at every start, so a paused root is listed from the +hash cache rather than left empty. +""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node import ops +from meshbay_node.config import GroupConfig, RootSpec +from meshbay_node.indexer import indexer as indexer_mod +from meshbay_node.indexer.cache import IndexCache +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.indexer.indexer import DirectoryIndexer +from meshbay_node.roots import RootSet +from meshbay_node.roster import Roster + +pytestmark = pytest.mark.asyncio + +GID = "g" * 32 + + +def _films(tmp_path: Path, n: int = 6) -> Path: + films = tmp_path / "Films" + films.mkdir() + for i in range(n): + (films / f"f{i}.mkv").write_bytes(f"film {i}".encode()) + return films + + +def _indexer(roots: RootSet, **kw) -> DirectoryIndexer: + return DirectoryIndexer(roots=roots, group_id=GID, + sk_node=Ed25519PrivateKey.generate(), gek=None, **kw) + + +def _names(idx: DirectoryIndexer) -> set[str]: + return {e.name for e in idx.index.entries} + + +def _count_reads(monkeypatch, after=None, at: int = 0) -> list[str]: + """Record every file actually read; run `after` once `at` have been.""" + read: list[str] = [] + real = indexer_mod._scan_file + + def counting(root, path): + read.append(path.name) + if after is not None and len(read) == at: + after() + return real(root, path) + + monkeypatch.setattr(indexer_mod, "_scan_file", counting) + return read + + +# ── The walk notices ───────────────────────────────────────────────────────── + +async def test_a_pause_stops_the_walk_at_the_next_file(tmp_path, monkeypatch): + roots = RootSet.build([{"path": str(_films(tmp_path))}]) + idx = _indexer(roots) + read = _count_reads(monkeypatch, lambda: setattr(roots.roots[0], "paused", True), at=2) + + await idx.initial_scan() + + assert len(read) == 2, f"read {len(read)} files after the pause" + assert len(_names(idx)) == 2, "the files hashed before the pause stay listed" + assert idx.progress.scanning is False + + +async def test_a_root_removed_mid_walk_is_not_read_to_the_end(tmp_path, monkeypatch): + films = _films(tmp_path) + other = tmp_path / "Other" + other.mkdir() + roots = RootSet.build([{"path": str(films)}]) + idx = _indexer(roots) + + def remove(): + idx.roots = RootSet.build([{"path": str(other)}]) + + read = _count_reads(monkeypatch, remove, at=1) + await idx.initial_scan() + + assert len(read) == 1, f"went on reading a removed root: {read}" + + +async def test_a_root_ejected_mid_walk_is_not_read_to_the_end(tmp_path, monkeypatch): + roots = RootSet.build([{"path": str(_films(tmp_path)), "removable": True}]) + idx = _indexer(roots) + read = _count_reads(monkeypatch, lambda: idx.eject_root("Films"), at=1) + + await idx.initial_scan() + + assert len(read) == 1, f"went on reading an ejected drive: {read}" + + +async def test_a_retarget_does_not_hide_the_pause_from_a_running_walk( + tmp_path, monkeypatch): + """The walk holds the Root it started with; a reload replaces every one.""" + films = _films(tmp_path) + roots = RootSet.build([{"path": str(films)}]) + idx = _indexer(roots) + + def reload_then_pause(): + idx.roots = RootSet.build([{"path": str(films)}]) + idx.roots.roots[0].paused = True + + read = _count_reads(monkeypatch, reload_then_pause, at=1) + await idx.initial_scan() + + assert len(read) == 1 + + +# ── The pause outlasts the walk ────────────────────────────────────────────── + +async def test_reconcile_leaves_a_paused_root_alone(tmp_path): + films = _films(tmp_path, n=1) + roots = RootSet.build([{"path": str(films)}]) + idx = _indexer(roots) + await idx.initial_scan() + await idx.pause_root("Films") + + (films / "new.mkv").write_bytes(b"new film") + await idx.reconcile() + + assert "new.mkv" not in _names(idx), "reconcile redid the work the pause stopped" + + +async def test_the_watcher_leaves_a_paused_root_alone(tmp_path): + films = _films(tmp_path, n=1) + idx = _indexer(RootSet.build([{"path": str(films)}])) + await idx.initial_scan() + await idx.pause_root("Films") + + new = films / "new.mkv" + new.write_bytes(b"new film") + await idx._update_entry(new, deleted=False) + + assert "new.mkv" not in _names(idx) + + +async def test_a_paused_root_is_listed_from_the_cache_and_nothing_is_read( + tmp_path, monkeypatch): + """ + After a restart the index is empty until the walk refills it. Skipping a + paused root would empty it for members; reading it would undo the pause. + """ + films = _films(tmp_path, n=3) + cache = IndexCache(db_path=tmp_path / "cache.db") + await cache.open() + try: + await _indexer(RootSet.build([{"path": str(films)}]), cache=cache).initial_scan() + (films / "unhashed.mkv").write_bytes(b"never hashed") + + roots = RootSet.build([{"path": str(films), "paused": True}]) + idx = _indexer(roots, cache=cache) + read = _count_reads(monkeypatch) + await idx.initial_scan() + + assert read == [] + assert _names(idx) == {"f0.mkv", "f1.mkv", "f2.mkv"} + assert idx.progress.scanning is False + finally: + await cache.close() + + +async def test_a_pause_mid_rescan_keeps_the_entries_it_had(tmp_path, monkeypatch): + """A rescan drops the root's entries before it walks; stopping it there + would leave the operator with half a library.""" + films = _films(tmp_path, n=4) + cache = IndexCache(db_path=tmp_path / "cache.db") + await cache.open() + try: + roots = RootSet.build([{"path": str(films)}]) + idx = _indexer(roots, cache=cache) + await idx.initial_scan() + for f in films.iterdir(): + f.write_bytes(f.read_bytes() + b" edited") # every cache row stale + + read = _count_reads(monkeypatch, lambda: setattr(roots.roots[0], "paused", True), + at=1) + await idx._rescan_root(roots.roots[0]) + + assert len(read) == 1 + assert len(_names(idx)) == 1 + finally: + await cache.close() + + +# ── Resume ─────────────────────────────────────────────────────────────────── + +async def test_resume_indexes_what_is_missing_and_keeps_what_was_enriched(tmp_path): + films = _films(tmp_path, n=2) + idx = _indexer(RootSet.build([{"path": str(films)}])) + await idx.initial_scan() + for e in idx.index.entries: + e.artist = "Some Artist" + await idx.pause_root("Films") + (films / "new.mkv").write_bytes(b"new film") + + await idx.resume_root("Films") + await asyncio.gather(*idx._scan_tasks) + + assert "new.mkv" in _names(idx) + kept = [e for e in idx.index.entries if e.name != "new.mkv"] + assert all(e.artist == "Some Artist" for e in kept), \ + "resume replaced entries it should have left alone" + + +async def test_resume_returns_before_the_walk_ends(tmp_path): + films = _films(tmp_path, n=1) + roots = RootSet.build([{"path": str(films)}]) + idx = _indexer(roots) + await idx.initial_scan() + await idx.pause_root("Films") + + async with idx._scan_lock: + await asyncio.wait_for(idx.resume_root("Films"), timeout=1) + assert idx.progress.queued == ["Films"] + await asyncio.gather(*idx._scan_tasks) + assert idx.progress.queued == [] + + +# ── The flag is the operator's, and survives ───────────────────────────────── + +def _state(tmp_path: Path, roots: RootSet, roster: Roster) -> dict: + index = GroupIndex(group_id=GID, sk_node=Ed25519PrivateKey.generate()) + return { + "groups_ctx": {GID: {"index": index, "roots": roots, "gek": None}}, + "config": SimpleNamespace(groups=[SimpleNamespace(id=GID, roots=[])]), + "roster": roster, + "node_user_id": "operator", + } + + +async def test_pause_and_resume_persist_through_the_roster(tmp_path): + films = _films(tmp_path, n=1) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + state = _state(tmp_path, RootSet.build([{"path": str(films)}]), roster) + + out = await ops.pause_root(state, GID, "films") + assert out["status"] == "paused" + assert out["roots"][0]["paused"] is True + assert out["roots"][0]["available"] is True, "a paused root is still served" + assert await roster.paused_roots(GID) == {"films"} + assert (await ops.pause_root(state, GID, "Films"))["status"] == "already_paused" + + out = await ops.resume_root(state, GID, "Films") + assert out["status"] == "resumed" + assert await roster.paused_roots(GID) == set() + finally: + await roster.close() + + +async def test_removing_a_root_forgets_its_pause(tmp_path): + """A directory added back later under the same name starts indexed.""" + films = _films(tmp_path, n=1) + keep = tmp_path / "Keep" + keep.mkdir() + conf = tmp_path / "node.toml" + conf.write_text( + f'[[groups]]\nid = "{GID}"\nname = "g"\n\n' + f'[[groups.roots]]\npath = "{films.as_posix()}"\n\n' + f'[[groups.roots]]\npath = "{keep.as_posix()}"\n') + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + state = _state(tmp_path, RootSet.build( + [{"path": str(films)}, {"path": str(keep)}]), roster) + state["config_path"] = str(conf) + state["config"] = SimpleNamespace(groups=[GroupConfig( + id=GID, roots=[RootSpec(path=str(films)), RootSpec(path=str(keep))])]) + await ops.pause_root(state, GID, "Films") + + await ops.remove_root(state, GID, "Films") + + assert await roster.paused_roots(GID) == set() + finally: + await roster.close() + + +async def test_a_new_group_can_be_paused_during_its_first_scan(tmp_path): + """A group added while the node runs has an indexer, and no context until + that first scan ends — the scan most worth pausing.""" + films = _films(tmp_path, n=1) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + roots = RootSet.build([{"path": str(films)}]) + idx = _indexer(roots) + state = _state(tmp_path, roots, roster) + state["groups_ctx"] = {} + state["indexers"] = {GID: idx} + + out = await ops.pause_root(state, GID, "Films") + + assert out["status"] == "paused" + assert roots.roots[0].paused is True + finally: + await roster.close() diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py index 0fe9f03..30bbc8a 100644 --- a/packages/meshbay-node/tests/test_roots.py +++ b/packages/meshbay-node/tests/test_roots.py @@ -290,9 +290,9 @@ def test_describe_reports_what_a_member_needs(tmp_path): described = roots.describe() assert described == [ {"name": "Media", "kind": "generic", "available": True, - "writable": True, "removable": False, "ejected": False}, + "writable": True, "removable": False, "ejected": False, "paused": False}, {"name": "Music", "kind": "audio", "available": True, - "writable": False, "removable": True, "ejected": False}, + "writable": False, "removable": True, "ejected": False, "paused": False}, ] # Deliberately no paths: a member is told what exists and whether it is # readable, not where on the operator's disk it lives. @@ -301,15 +301,16 @@ def test_describe_reports_what_a_member_needs(tmp_path): def test_describe_names_a_root_and_never_a_path(tmp_path): """ - What a member is told about a root: that it exists, and whether it is - readable and writable. Never where on the operator's disk it lives — that - is the operator's own view (`with_paths`), over their own channel. + What a member is told about a root: that it exists, whether it is + readable and writable, and whether its indexing is paused. Never where on + the operator's disk it lives — that is the operator's own view + (`with_paths`), over their own channel. """ (tmp_path / "Media").mkdir() roots = RootSet.build([_spec(tmp_path / "Media", writable=True)]) described = roots.describe()[0] assert set(described) == {"name", "kind", "available", "writable", - "removable", "ejected"} + "removable", "ejected", "paused"} # ── SAFE_UPLOAD_NAME ──────────────────────────────────────────────────────── -- cgit v1.2.3