From b7e39a6e3836930fcc7634c8c6f67b9392bcb9b0 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Wed, 9 Sep 2026 16:02:32 +0200 Subject: [PATCH 1/2] feat(remote): add a persistent watch channel per remote host The periodic mirror pull (floored at 60s) stays as the reconciliation path, but a live host no longer waits up to 5 minutes to show up: one ssh -tt inotifywait child per alias now pushes a coalesced signal into remoteIndexer.refreshHostNow(), the same refresh path the timer takes, narrowed to the one host that changed. -tt is required to avoid leaking an orphaned inotifywait on the remote host once the local ssh is killed. A host missing inotifywait is marked unwatchable after one attempt and left to the periodic cycle, never retried in a loop. --- .ai/contexts/session-cache.md | 19 +++- main.js | 22 +++- remote-index.js | 44 ++++++-- remote-watch.js | 189 ++++++++++++++++++++++++++++++++ test/remote-index.test.js | 125 +++++++++++++++++++++ test/remote-watch.test.js | 201 ++++++++++++++++++++++++++++++++++ 6 files changed, 587 insertions(+), 13 deletions(-) create mode 100644 remote-watch.js create mode 100644 test/remote-watch.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 45e39189..34f79e6d 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -103,12 +103,23 @@ or deleted from here. and every renderer id — it is not worth doing before a second host exists. **If you ever see two sessions fighting over a star or a title, this is why.** -- **The mirror is pulled, never watched.** `fs.watch` cannot cross SSH +- **The mirror is pulled on a timer, floored at 60 s, as the reconciliation + path — it never goes away.** `fs.watch` cannot cross SSH (inotify/FSEvents/ReadDirectoryChangesW are kernel-local), and the local watcher at `main.js` `startProjectsWatcher()` is deliberately not pointed at the - mirror — it would fire on our own `scp` writes, not on remote activity. A timer - drives it instead, floored at 60 s: a tighter loop costs latency and VPS CPU for - a "where is my session at" use case that does not need it. + mirror — it would fire on our own `scp` writes, not on remote activity. + Issue #240 adds a push channel alongside it (below) so a live host is not + stale for up to 5 minutes; the pull remains the ground truth and the only + path for a host with no push channel (see below). + +### Remote hosts — watch channel (issue #240) + +`remote-watch.js` keeps one long-lived `ssh -tt … inotifywait` child per +declared alias and calls `remoteIndexer.refreshHostNow(alias)` (the periodic +cycle's per-host entry point) on a coalesced "this host changed" signal — +never on every line, and never in place of the periodic pull. Full rationale, +the exact remote command, and the mutation proofs are in +`.work-files/switchboard/remote-watch-report.md`. - **One `ssh` inventory, then only the deltas.** `remote-transport.js` runs `find .claude/projects -type f -name '*.jsonl' -printf '%T@ %s %P diff --git a/main.js b/main.js index f338286f..7448741b 100644 --- a/main.js +++ b/main.js @@ -459,10 +459,11 @@ const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCache const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file'); // --- Remote SSH hosts (observation only) — see .ai/contexts/session-cache.md --- -const { isRemoteFolder, parseFolderKey, joinFolderKey } = require('./remote-hosts'); +const { isRemoteFolder, parseFolderKey, joinFolderKey, enabledHosts } = require('./remote-hosts'); const REMOTE_READ_ONLY = 'remote sessions are read-only — this build observes them, it does not attach to them'; const { createSshTransport } = require('./remote-transport'); const { createRemoteIndexer } = require('./remote-index'); +const { createRemoteWatcher } = require('./remote-watch'); const remoteTransport = createSshTransport({ log }); const remoteIndexer = createRemoteIndexer({ @@ -478,6 +479,22 @@ const remoteIndexer = createRemoteIndexer({ log, }); +// see .ai/contexts/session-cache.md ("Remote hosts — watch channel") +const remoteWatcher = createRemoteWatcher({ log }); +let watchedAliases = new Set(); +function onRemoteWatchEvent(alias) { remoteIndexer.refreshHostNow(alias).catch(() => {}); } +function syncRemoteWatchers() { + const declared = enabledHosts((getSetting('global') || {}).remoteHosts); + const wanted = new Set(declared.map(h => h.alias)); + for (const alias of watchedAliases) { + if (!wanted.has(alias)) remoteWatcher.stop(alias); + } + for (const host of declared) { + if (!remoteWatcher.isRunning(host.alias)) remoteWatcher.start(host.alias, onRemoteWatchEvent); + } + watchedAliases = wanted; +} + // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") const remoteAttachAdapter = createTmuxAttachAdapter({ spawnPty: (file, args, ptyOpts) => spawnPty(file, args, { ...ptyOpts, cwd: os.homedir(), env: cleanPtyEnv }), @@ -1476,6 +1493,7 @@ ipcMain.handle('set-setting', (_event, key, value) => { ipcMain.handle('remote-hosts-apply', () => { try { const running = remoteIndexer.restart(); + syncRemoteWatchers(); return { ok: true, running }; } catch (err) { return { ok: false, error: err.message }; @@ -2649,6 +2667,7 @@ if (!gotSingleInstanceLock) { startProjectsWatcher(); // No declared host => no timer and no ssh call. see .ai/contexts/session-cache.md ("Remote SSH hosts") remoteIndexer.start(); + syncRemoteWatchers(); cliSessionState.ensureWatching(); // Remove IDE lock files left behind by a crashed instance whose PID was // reused (the function only unlinks locks matching our own pid). @@ -2790,6 +2809,7 @@ app.on('before-quit', () => { cliSessionState.stop(); // Stops the timer and SIGKILLs any ssh/scp still in flight. remoteIndexer.dispose(); + remoteWatcher.stopAll(); // Kill all PTY processes on quit for (const [id, session] of activeSessions) { diff --git a/remote-index.js b/remote-index.js index 1c3ac246..30d0449f 100644 --- a/remote-index.js +++ b/remote-index.js @@ -17,6 +17,12 @@ const NOOP_LOG = { info() {}, warn() {}, error() {} }; // see .ai/contexts/session-cache.md ("Remote hosts backoff") const MAX_BACKOFF_MS = 30 * 60 * 1000; +// see .ai/contexts/session-cache.md ("Remote hosts backoff") +function backoffDelayMs(failures, intervalMs) { + if (failures <= 0) return 0; + return Math.min(intervalMs * Math.pow(2, failures - 1), MAX_BACKOFF_MS); +} + /** * Periodic mirror + index of every declared SSH host. * see .ai/contexts/session-cache.md ("Remote SSH hosts") @@ -47,6 +53,7 @@ function createRemoteIndexer(ctx) { const remoteSessions = new Map(); // alias -> sessions array, from the same ssh cycle as the inventory const remoteSessionsAt = new Map(); // alias -> epoch ms of the last cycle that did not throw const hostBackoff = new Map(); // alias -> { failures, lastError, nextAttemptAt } + const hostInFlight = new Set(); function backoffState(alias) { let s = hostBackoff.get(alias); @@ -57,12 +64,6 @@ function createRemoteIndexer(ctx) { return s; } - // see .ai/contexts/session-cache.md ("Remote hosts backoff") - function backoffDelayMs(failures, intervalMs) { - if (failures <= 0) return 0; - return Math.min(intervalMs * Math.pow(2, failures - 1), MAX_BACKOFF_MS); - } - function onHostSuccess(alias) { const state = backoffState(alias); if (state.failures > 0) { @@ -211,6 +212,7 @@ function createRemoteIndexer(ctx) { try { for (const host of list) { if (stopped) break; + if (hostInFlight.has(host.alias)) continue; const state = backoffState(host.alias); if (now() < state.nextAttemptAt) continue; // still backing off: no attempt, no log, no ssh try { @@ -231,6 +233,32 @@ function createRemoteIndexer(ctx) { return { skipped: false, hosts: list.length, changed, errors }; } + // see .ai/contexts/session-cache.md ("Remote hosts — watch channel") + async function refreshHostNow(alias) { + if (stopped || inFlight || hostInFlight.has(alias)) return { skipped: true }; + const host = hosts().find(h => h.alias === alias); + if (!host) return { skipped: true }; + const state = backoffState(alias); + if (now() < state.nextAttemptAt) return { skipped: true }; + const intervalMs = normalizeRefreshMs(ctx.getRefreshMs ? ctx.getRefreshMs() : undefined); + hostInFlight.add(alias); + let changed = false; + let error = null; + try { + changed = await refreshHost(host); + onHostSuccess(alias); + remoteSessionsAt.set(alias, now()); + } catch (err) { + remoteSessions.set(alias, []); + onHostFailure(alias, err, intervalMs); + error = err.message; + } finally { + hostInFlight.delete(alias); + } + if (changed && ctx.notify) ctx.notify(); + return { skipped: false, changed, error }; + } + function start() { stopped = false; const list = hosts(); @@ -276,11 +304,11 @@ function createRemoteIndexer(ctx) { } return { - start, stop, dispose, restart, refreshNow, + start, stop, dispose, restart, refreshNow, refreshHostNow, isRunning: () => timer !== null, getRemoteSessions, getRemoteHostState, }; } -module.exports = { createRemoteIndexer }; +module.exports = { createRemoteIndexer, backoffDelayMs }; diff --git a/remote-watch.js b/remote-watch.js new file mode 100644 index 00000000..0f4fdf57 --- /dev/null +++ b/remote-watch.js @@ -0,0 +1,189 @@ +// see .ai/contexts/session-cache.md ("Remote hosts — watch channel") +'use strict'; + +const { REMOTE_PROJECTS_REL, REMOTE_SESSIONS_REL } = require('./remote-transport'); +const { isSafeRelPath } = require('./remote-hosts'); +const { backoffDelayMs } = require('./remote-index'); + +const NOOP_LOG = { info() {}, warn() {}, error() {} }; +const NO_INOTIFYWAIT_MARKER = 'SWITCHBOARD-NO-INOTIFYWAIT'; +const NO_INOTIFYWAIT_EXIT_CODE = 44; +const SESSION_FILE_RE = /^[0-9]+\.json$/; +const PROJECTS_EVENTS = 'modify,close_write,create,moved_to'; +const SESSIONS_EVENTS = 'close_write,create,delete,moved_to'; +const COALESCE_MS = 1000; +const RESTART_BASE_MS = 5000; +const HEALTHY_MS = 30000; + +function buildWatchCommand() { + return `if ! command -v inotifywait >/dev/null 2>&1; then echo ${NO_INOTIFYWAIT_MARKER}; exit ${NO_INOTIFYWAIT_EXIT_CODE}; fi; ` + + `mkdir -p '${REMOTE_PROJECTS_REL}' '${REMOTE_SESSIONS_REL}'; ` + + `inotifywait -m -r -e ${PROJECTS_EVENTS} --format 'P|%w%f' '${REMOTE_PROJECTS_REL}' & p=$!; ` + + `inotifywait -m -e ${SESSIONS_EVENTS} --format 'S|%w%f' '${REMOTE_SESSIONS_REL}' & s=$!; ` + + `wait $p $s`; +} + +function buildSshArgs(alias) { + return ['-tt', '-o', 'BatchMode=yes', alias, buildWatchCommand()]; +} + +function parseWatchLine(line) { + if (typeof line !== 'string' || line.length < 3 || line[1] !== '|') return null; + const kind = line[0]; + const raw = line.slice(2); + if (kind === 'P') { + const prefix = REMOTE_PROJECTS_REL + '/'; + if (!raw.startsWith(prefix)) return null; + const rel = raw.slice(prefix.length); + return isSafeRelPath(rel) ? { kind: 'project', rel } : null; + } + if (kind === 'S') { + const prefix = REMOTE_SESSIONS_REL + '/'; + if (!raw.startsWith(prefix)) return null; + const rel = raw.slice(prefix.length); + return SESSION_FILE_RE.test(rel) ? { kind: 'session', rel } : null; + } + return null; +} + +function createRemoteWatcher(opts = {}) { + const spawnFn = opts.spawn || require('child_process').spawn; + const log = opts.log || NOOP_LOG; + const setT = (opts.timers && opts.timers.setTimeout) || setTimeout; + const clearT = (opts.timers && opts.timers.clearTimeout) || clearTimeout; + + const states = new Map(); + + function killChild(s) { + if (s.restartTimer) { clearT(s.restartTimer); s.restartTimer = null; } + if (s.child) { + const child = s.child; + s.child = null; + try { child.kill(); } catch {} + } + } + + function emitCoalesced(s, kind) { + if (s.cooldown[kind]) { s.pending[kind] = true; return; } + s.onEvent(s.alias, kind); + s.cooldown[kind] = true; + const t = setT(() => { + s.cooldown[kind] = false; + if (s.pending[kind]) { s.pending[kind] = false; emitCoalesced(s, kind); } + }, COALESCE_MS); + if (t && t.unref) t.unref(); + } + + function handleLine(s, rawLine) { + const line = rawLine.replace(/\r$/, ''); + if (!line) return; + if (line.includes(NO_INOTIFYWAIT_MARKER)) { + s.unwatchable = true; + log.warn(`[remote-watch:${s.alias}] inotifywait is not installed on this host — ` + + 'watch channel disabled, periodic refresh still covers it'); + killChild(s); + return; + } + const parsed = parseWatchLine(line); + if (!parsed) return; + emitCoalesced(s, parsed.kind); + } + + function onData(s, chunk) { + s.buf += chunk; + let idx; + while ((idx = s.buf.indexOf('\n')) !== -1) { + const line = s.buf.slice(0, idx); + s.buf = s.buf.slice(idx + 1); + handleLine(s, line); + } + } + + function scheduleRestart(s) { + const delay = backoffDelayMs(s.failures, RESTART_BASE_MS); + s.restartTimer = setT(() => { s.restartTimer = null; spawnChild(s); }, delay); + if (s.restartTimer && s.restartTimer.unref) s.restartTimer.unref(); + } + + function onExit(s) { + s.child = null; + if (s.stopped || s.unwatchable) return; + s.failures = (Date.now() - s.spawnedAt) < HEALTHY_MS ? s.failures + 1 : 0; + scheduleRestart(s); + } + + function spawnChild(s) { + if (s.stopped || s.unwatchable) return; + let child; + try { + child = spawnFn('ssh', buildSshArgs(s.alias), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + log.warn(`[remote-watch:${s.alias}] spawn failed: ${err.message}`); + s.failures += 1; + scheduleRestart(s); + return; + } + s.child = child; + s.buf = ''; + s.spawnedAt = Date.now(); + if (child.stdout) { + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => onData(s, chunk)); + } + if (child.stderr) child.stderr.on('data', () => {}); + child.on('error', () => {}); + child.on('close', () => onExit(s)); + } + + function getState(alias) { + let s = states.get(alias); + if (!s) { + s = { + alias, child: null, buf: '', stopped: true, unwatchable: false, + failures: 0, spawnedAt: 0, restartTimer: null, onEvent: null, + cooldown: { project: false, session: false }, + pending: { project: false, session: false }, + }; + states.set(alias, s); + } + return s; + } + + function start(alias, onEvent) { + if (typeof alias !== 'string' || !alias || typeof onEvent !== 'function') return; + const s = getState(alias); + if (!s.stopped && !s.unwatchable) return; + s.stopped = false; + s.unwatchable = false; + s.failures = 0; + s.onEvent = onEvent; + spawnChild(s); + } + + function stop(alias) { + const s = states.get(alias); + if (!s) return; + s.stopped = true; + killChild(s); + } + + function stopAll() { + for (const alias of [...states.keys()]) stop(alias); + } + + function isRunning(alias) { + const s = states.get(alias); + return !!(s && !s.stopped && !s.unwatchable); + } + + return { start, stop, stopAll, isRunning }; +} + +module.exports = { + createRemoteWatcher, + parseWatchLine, + buildWatchCommand, + buildSshArgs, + NO_INOTIFYWAIT_MARKER, + NO_INOTIFYWAIT_EXIT_CODE, +}; diff --git a/test/remote-index.test.js b/test/remote-index.test.js index ef2b564d..98ad543b 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -514,3 +514,128 @@ test('failure logging is throttled: only the first failure and tier changes are assert.ok(warnings.length <= 6, `only the tier changes are logged, got ${warnings.length}`); } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); + +// refreshHostNow(alias) — issue #240's per-host entry point for the watch +// channel: the same refresh path the periodic timer takes, narrowed to one +// host, so a push signal never pays for refreshing every declared host. +test('refreshHostNow refreshes only the named host, not its peers', async () => { + const dataDir = tmp('idx-hostnow-scope'); + try { + const scans = []; + let notified = 0; + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }, { alias: 'other' }], + dataDir, + transport: {}, + scanFolders: (args) => { scans.push(args.folderPrefix); return Promise.resolve({ ok: true }); }, + listIndexedFolderKeys: () => [], + notify: () => { notified++; }, + timers: fakeTimers(), + sync: async ({ alias, projectsDir }) => { + fs.mkdirSync(path.join(projectsDir, '-srv-x'), { recursive: true }); + return { fetched: 1, unchanged: 0, removed: 0, failed: 0, total: 1, changedFolders: new Set(['-srv-x']) }; + }, + }); + + const r = await indexer.refreshHostNow('vps'); + + assert.equal(r.skipped, false); + assert.equal(r.changed, true); + assert.deepEqual(scans, ['vps'], 'only the named host was scanned'); + assert.equal(notified, 1, 'the same notify path the periodic cycle uses fires on change'); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('refreshHostNow reports skipped for an alias that is not declared', async () => { + const dataDir = tmp('idx-hostnow-unknown'); + try { + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + sync: () => { throw new Error('sync must not be called for an unknown alias'); }, + }); + + assert.deepEqual(await indexer.refreshHostNow('ghost'), { skipped: true }); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('refreshHostNow honors the same per-host backoff refreshNow uses', async () => { + const dataDir = tmp('idx-hostnow-backoff'); + try { + const clock = fakeClock(0); + let attempts = 0; + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'dead' }], + getRefreshMs: () => 60_000, + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + now: clock, + sync: async () => { attempts++; throw new Error('ssh: connect to host dead port 22: timed out'); }, + }); + + await indexer.refreshHostNow('dead'); + assert.equal(attempts, 1); + const state = indexer.getRemoteHostState('dead'); + assert.ok(state.nextAttemptAt > clock(), 'a failure must arm the backoff exactly as refreshNow does'); + + // Still backing off: a second watch-triggered call must not spend another ssh attempt. + await indexer.refreshHostNow('dead'); + assert.equal(attempts, 1, 'a host still backing off is skipped, not re-attempted'); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('refreshHostNow and the periodic cycle never overlap on the same host', async () => { + const dataDir = tmp('idx-hostnow-overlap'); + try { + let inflightCount = 0; + let maxInflight = 0; + let releasePeriodic; + const periodicGate = new Promise((resolve) => { releasePeriodic = resolve; }); + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + sync: async ({ projectsDir }) => { + inflightCount++; + maxInflight = Math.max(maxInflight, inflightCount); + await periodicGate; + fs.mkdirSync(path.join(projectsDir, '-srv-x'), { recursive: true }); + inflightCount--; + return { fetched: 1, unchanged: 0, removed: 0, failed: 0, total: 1, changedFolders: new Set(['-srv-x']) }; + }, + }); + + const periodic = indexer.refreshNow(); + // A missing overlap guard would make this call join the same in-flight + // sync() and hang on periodicGate forever — race a timeout so a broken + // guard fails the test instead of hanging the whole run. + let timeoutHandle; + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error( + 'refreshHostNow did not return promptly — it is not skipping the host the periodic cycle already owns', + )), 2000); + if (timeoutHandle.unref) timeoutHandle.unref(); + }); + let watchTriggered; + try { + watchTriggered = await Promise.race([indexer.refreshHostNow('vps'), timeout]); + } finally { + clearTimeout(timeoutHandle); + } + assert.equal(watchTriggered.skipped, true, 'a watch signal must not race the periodic cycle for the same host'); + + releasePeriodic(); + await periodic; + assert.equal(maxInflight, 1, 'the two paths never ran the transport for the same host concurrently'); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); diff --git a/test/remote-watch.test.js b/test/remote-watch.test.js new file mode 100644 index 00000000..90ac7863 --- /dev/null +++ b/test/remote-watch.test.js @@ -0,0 +1,201 @@ +'use strict'; + +// The remote watch channel, fully injected: no real ssh, no real timers. +// Properties proven here (issue #240, see .ai/contexts/session-cache.md, +// "Remote hosts — watch channel"): +// 1. -tt is passed — without it, a killed local ssh leaves the remote +// inotifywait running (measured: one orphan per restart). +// 2. A missing inotifywait marks the host unwatchable and never retries. +// 3. A burst of events collapses to a coalesced signal, not one per line. +// 4. A line that does not parse to a safe path is dropped, not forwarded. +// 5. A child that exits restarts on the same backoff shape as remote-index. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('events'); +const { Readable } = require('stream'); + +const { + createRemoteWatcher, parseWatchLine, buildSshArgs, NO_INOTIFYWAIT_MARKER, +} = require('../remote-watch'); +const { backoffDelayMs } = require('../remote-index'); +const { REMOTE_PROJECTS_REL, REMOTE_SESSIONS_REL } = require('../remote-transport'); + +const silentLog = { info() {}, warn() {}, error() {} }; + +function fakeChild() { + const child = new EventEmitter(); + child.stdout = new Readable({ read() {} }); + child.stderr = new Readable({ read() {} }); + child.killed = 0; + child.kill = () => { child.killed++; child.emit('close', null); }; + return child; +} + +/** spawn stub: records every call and hands the child back to drive by hand. */ +function spawnRecorder() { + const calls = []; + const spawn = (cmd, args) => { + const child = fakeChild(); + calls.push({ cmd, args, child }); + return child; + }; + spawn.calls = calls; + return spawn; +} + +/** setTimeout/clearTimeout double: nothing fires until the test says so. */ +function fakeTimers() { + const scheduled = []; + return { + scheduled, + setTimeout: (fn, ms) => { const h = { fn, ms, cleared: false }; scheduled.push(h); return h; }, + clearTimeout: (h) => { if (h) h.cleared = true; }, + }; +} + +test('buildSshArgs passes -tt, BatchMode, and keeps alias/command as separate argv elements', () => { + const args = buildSshArgs('planificator'); + assert.ok(args.includes('-tt'), '-tt is mandatory: without it a killed ssh leaves the remote inotifywait running'); + assert.ok(args.includes('BatchMode=yes')); + assert.equal(args[args.length - 2], 'planificator', 'alias is its own argv element, never concatenated'); + const command = args[args.length - 1]; + assert.match(command, /inotifywait/); + assert.ok(command.includes(REMOTE_PROJECTS_REL)); + assert.ok(command.includes(REMOTE_SESSIONS_REL)); +}); + +test('parseWatchLine recovers kind and rel path for a well-formed line', () => { + assert.deepEqual( + parseWatchLine(`P|${REMOTE_PROJECTS_REL}/-srv-a/session.jsonl`), + { kind: 'project', rel: '-srv-a/session.jsonl' }, + ); + assert.deepEqual( + parseWatchLine(`S|${REMOTE_SESSIONS_REL}/1234.json`), + { kind: 'session', rel: '1234.json' }, + ); +}); + +test('parseWatchLine drops a line that does not parse, instead of guessing', () => { + assert.equal(parseWatchLine(''), null); + assert.equal(parseWatchLine('garbage'), null); + assert.equal(parseWatchLine(`X|${REMOTE_PROJECTS_REL}/a.jsonl`), null, 'unknown kind prefix'); + assert.equal(parseWatchLine(`P|.claude/other/a.jsonl`), null, 'wrong root entirely'); + assert.equal( + parseWatchLine(`P|${REMOTE_PROJECTS_REL}/../../etc/passwd`), null, + 'traversal beyond the declared root must never be forwarded', + ); + assert.equal(parseWatchLine(`S|${REMOTE_SESSIONS_REL}/not-a-descriptor.txt`), null, 'wrong descriptor filename shape'); + assert.equal(parseWatchLine(`S|${REMOTE_SESSIONS_REL}/sub/1234.json`), null, 'sessions dir is not recursive'); +}); + +test('a missing inotifywait marks the host unwatchable and never schedules a retry', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + const events = []; + + watcher.start('vps', (alias, kind) => events.push({ alias, kind })); + assert.equal(spawn.calls.length, 1); + + const { child } = spawn.calls[0]; + child.stdout.push(NO_INOTIFYWAIT_MARKER + '\n'); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + child.emit('close', 44); + + assert.equal(watcher.isRunning('vps'), false, 'a missing binary must never look like a healthy watcher'); + assert.equal(events.length, 0); + assert.ok(timers.scheduled.every(h => h.cleared), 'no restart may be scheduled once marked unwatchable'); + assert.equal(spawn.calls.length, 1, 'exactly one attempt — the periodic cycle already covers this host'); +}); + +test('a burst of same-kind events collapses to far fewer callbacks than events', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + const events = []; + + watcher.start('vps', (alias, kind) => events.push({ alias, kind })); + const { child } = spawn.calls[0]; + const rel = `${REMOTE_PROJECTS_REL}/-srv-a/session.jsonl`; + for (let i = 0; i < 100; i++) child.stdout.push(`P|${rel}\n`); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(events.length >= 1, 'at least the leading edge must fire'); + assert.ok(events.length <= 2, `100 rapid events must coalesce to a leading + at most one trailing flush, got ${events.length}`); + assert.ok(events.every(e => e.kind === 'project')); +}); + +test('project and session events are distinguishable in the callback', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + const events = []; + + watcher.start('vps', (alias, kind) => events.push({ alias, kind })); + const { child } = spawn.calls[0]; + child.stdout.push(`P|${REMOTE_PROJECTS_REL}/-srv-a/session.jsonl\n`); + child.stdout.push(`S|${REMOTE_SESSIONS_REL}/1234.json\n`); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(events.sort((a, b) => a.kind.localeCompare(b.kind)), [ + { alias: 'vps', kind: 'project' }, + { alias: 'vps', kind: 'session' }, + ]); +}); + +test('a live watcher restarts on exit using the same backoff shape as remote-index', () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + + watcher.start('vps', () => {}); + assert.equal(spawn.calls.length, 1); + + // Three consecutive rapid deaths: the per-attempt delay must follow + // remote-index's own doubling-with-cap formula, not a locally invented one + // (a linear or constant reimplementation matches at the 1st failure but + // diverges by the 3rd). + for (let failures = 1; failures <= 3; failures++) { + const last = spawn.calls[spawn.calls.length - 1]; + last.child.emit('close', 1); // dies almost immediately -> counts as a failure + + const pending = timers.scheduled.filter(h => !h.cleared).pop(); + assert.ok(pending, `a restart must be scheduled after failure ${failures}`); + assert.equal(pending.ms, backoffDelayMs(failures, 5000), + 'the delay must come from remote-index\'s own backoff formula, not a reimplementation'); + + pending.fn(); + } + assert.equal(spawn.calls.length, 4, 'each scheduled restart actually respawns the watcher'); +}); + +test('stop() kills the live child and cancels any pending restart', () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + + watcher.start('vps', () => {}); + const { child } = spawn.calls[0]; + watcher.stop('vps'); + + assert.equal(child.killed, 1); + assert.equal(watcher.isRunning('vps'), false); + assert.ok(timers.scheduled.every(h => h.cleared), 'stop() must not leave a restart pending'); +}); + +test('stopAll() tears down every tracked alias', () => { + const spawn = spawnRecorder(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers: fakeTimers() }); + + watcher.start('vps', () => {}); + watcher.start('other', () => {}); + watcher.stopAll(); + + assert.equal(watcher.isRunning('vps'), false); + assert.equal(watcher.isRunning('other'), false); + assert.equal(spawn.calls.filter(c => c.child.killed).length, 2); +}); From e44c9a401edb5aca4a4a29eeebbbf17416c20413 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Wed, 9 Sep 2026 16:08:37 +0200 Subject: [PATCH 2/2] fix(remote): space event-driven refreshes 15s apart, not 1s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every coalesced signal opens a full ssh cycle for the host. At a one-second window a continuously writing remote session would drive roughly one connection per second — 3600 handshakes an hour against a host that gets 12 today, the opposite of what the watch channel is for. The leading edge still fires at once, so an idle-to-active transition is visible immediately; sustained activity now costs at most four connections a minute. --- remote-watch.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/remote-watch.js b/remote-watch.js index 0f4fdf57..72285f19 100644 --- a/remote-watch.js +++ b/remote-watch.js @@ -11,7 +11,7 @@ const NO_INOTIFYWAIT_EXIT_CODE = 44; const SESSION_FILE_RE = /^[0-9]+\.json$/; const PROJECTS_EVENTS = 'modify,close_write,create,moved_to'; const SESSIONS_EVENTS = 'close_write,create,delete,moved_to'; -const COALESCE_MS = 1000; +const COALESCE_MS = 15000; const RESTART_BASE_MS = 5000; const HEALTHY_MS = 30000;