From 80ac882346b474bb7ea4e8c8afd4694bd13dd9dd Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 10:17:49 +0200 Subject: feat(node): skip a file the disk stops answering for, then pause its root A Windows tester's scan stuck for good on one directory of a large disk: one read that never returned held the indexer's only thread, and with it every root behind that one. - A disk call is timed by its silence, not its length: each directory entry listed and each megabyte read counts as an answer. After 60 s of silence the call is abandoned on its daemon thread, the file is noted unread and the scan goes on. - Three silent files in a row pause the root (not persisted). Resume skips the files already known to give no answer; a restart, a replug or a change to the file retries them. - The walk is per directory with os.scandir: a subdirectory that cannot be listed costs itself, and a listing that stops answering pauses the root at once. A scan no longer stats anything from the event loop. - The log names the file after 10 s of silence, the indexing band shows it, and Group settings shows the paused root, where it stuck and the unread files with their reason, on the node's machine only. - generate_http_api.py writes LF on Windows too. Verified with real kernel-blocked opens (an unacknowledged oplock) in the installed frozen app. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/group-settings.js | 61 +++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index a666c29..8ed8eec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -46,6 +46,9 @@ async function readLocalNode(node, groupId, attempts = NODE_CHECK_ATTEMPTS, // ── Shared Directories Table ──────────────────────────────────────────── +// How often the table asks its node what it could not read. +const TROUBLE_POLL_MS = 10000; + /** * A group's root directories, and the operator's controls over them. * @@ -98,6 +101,10 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, // reason nothing happened. const [msg, setMsg] = useState(null); const [indexProgress, setIndexProgress] = useState(null); + // What the node could not read and the folders it paused by itself, as its + // loopback API says them; `troubleTick` asks again after a change here. + const [trouble, setTrouble] = useState(null); + const [troubleTick, setTroubleTick] = useState(0); const say = useCallback((text) => setMsg(text ? { text, error: false } : null), []); const refuse = useCallback((text) => setMsg({ text, error: true }), []); @@ -161,6 +168,26 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, const canEdit = isLocal || overMnp || overLoopback; const canWiden = isLocal || onNodeMachine; + // Paths, so only where the loopback API answers: on the node's own machine. + // An operator elsewhere sees the folder paused, as any member does. + useEffect(() => { + if (!onNodeMachine) { setTrouble(null); return undefined; } + let stopped = false; + const read = async () => { + try { + const s = await platform.node.op('groupIndexStatus', { groupId }); + const next = { unread: s.unread || [], total: s.unread_total || 0, + stalled: s.stalled || [] }; + if (!stopped) { + setTrouble((prev) => JSON.stringify(prev) === JSON.stringify(next) ? prev : next); + } + } catch { /* busy, or mid-reload: the next poll asks again */ } + }; + read(); + const id = setInterval(read, TROUBLE_POLL_MS); + return () => { stopped = true; clearInterval(id); }; + }, [onNodeMachine, groupId, troubleTick]); + // Deliberately no index refresh after a root change. // // Adding a root makes the node reload, which rescans — minutes on a real @@ -182,7 +209,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, } catch (err) { refuse(platform.bridgeMessage(err)); return false; - } finally { setBusy(false); } + } finally { setBusy(false); setTroubleTick((n) => n + 1); } }, [onRootsChange, refuse]); const doUpdateRoot = useCallback(async (rootName, updates) => { @@ -296,6 +323,34 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,

${t('settings_node.roots_local_only_hint')}

`; + // A folder the node paused because its disk stopped answering, and the + // files it gave up on. Under the table, with the paths: this is the list an + // operator used to need the node's log for. + const stalledAt = {}; + for (const s of (trouble && trouble.stalled) || []) { + if (displayRoots.some((r) => r.name === s.root && r.paused)) stalledAt[s.root] = s.path; + } + const troubleNote = (isLocal || !trouble) ? '' : html` + ${Object.entries(stalledAt).map(([name, file]) => html` +

+ ${t('node.root_stalled_hint', { name, file })}

`)} + ${trouble.total > 0 && html` +
+ ${t('node.unread_summary', { count: trouble.total })} +
    + ${trouble.unread.map((u) => html` +
  • + ${u.path} + ${u.reason === 'no_answer' + ? t('node.unread_no_answer') : u.reason} +
  • `)} +
+ ${trouble.total > trouble.unread.length && html` +

${t('node.unread_more', + { count: trouble.total - trouble.unread.length })}

`} +
`} + `; + if (!displayRoots.length) { return html`
@@ -336,6 +391,9 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, ${t('node.unavailable')}`} ${!isLocal && r.paused && html` ${t('node.root_paused')}`} + ${!isLocal && stalledAt[r.name] && html` + ${t('node.root_stalled')}`} ${hasPaths && html` ${r.path || ''}`} @@ -390,6 +448,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, `; })} + ${troubleNote} ${localOnlyHint} ${addControls} ${message} -- cgit v1.2.3