diff --git a/src/mount/relayfile-binary.test.ts b/src/mount/relayfile-binary.test.ts index 880d92a..9089ba7 100644 --- a/src/mount/relayfile-binary.test.ts +++ b/src/mount/relayfile-binary.test.ts @@ -27,7 +27,7 @@ async function withTempDir(fn: (dir: string) => Promise): Promise { async function writeState( dir: string, - state: { workspaceId?: string; lastReconcileAt?: string; pid?: number }, + state: { workspaceId?: string; lastReconcileAt?: string; pid?: number; daemon?: { pid?: number } }, ): Promise { const statePath = join(dir, 'state.json') await writeFile(statePath, JSON.stringify(state), 'utf8') @@ -170,6 +170,62 @@ describe('checkMountStaleness', () => { }) }) + it('treats a fresh pid-less state as healthy (CLI-daemonized mount records no pid)', async () => { + await withTempDir(async (dir) => { + const statePath = await writeState(dir, { + workspaceId: 'rw_test', + lastReconcileAt: new Date().toISOString(), + // no pid: relayfile start --background does not always record one + }) + + expect(checkMountStaleness(statePath, 'rw_test')).toEqual({ stale: false }) + }) + }) + + it('uses daemon.pid for liveness when the top-level pid is absent (CLI-daemonized mount)', async () => { + await withTempDir(async (dir) => { + const statePath = await writeState(dir, { + workspaceId: 'rw_test', + lastReconcileAt: new Date().toISOString(), + daemon: { pid: process.pid }, + }) + + expect(checkMountStaleness(statePath, 'rw_test')).toEqual({ stale: false, pid: process.pid }) + }) + }) + + it('marks a stale mount via a dead daemon.pid even within the reconcile window', async () => { + await withTempDir(async (dir) => { + const statePath = await writeState(dir, { + workspaceId: 'rw_test', + lastReconcileAt: new Date().toISOString(), + daemon: { pid: 12345 }, + }) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('not found'), { code: 'ESRCH' }) + }) + + expect(checkMountStaleness(statePath, 'rw_test')).toEqual({ + stale: true, + reason: 'mount process (pid 12345) is not running', + pid: 12345, + }) + }) + }) + + it('still marks a pid-less state stale when the reconcile timestamp is old', async () => { + await withTempDir(async (dir) => { + const statePath = await writeState(dir, { + workspaceId: 'rw_test', + lastReconcileAt: new Date(Date.now() - 16 * 60 * 1000).toISOString(), + }) + + const result = checkMountStaleness(statePath, 'rw_test') + expect(result.stale).toBe(true) + expect(result.reason).toMatch(/^last reconcile 16m ago$/u) + }) + }) + it('returns non-stale with the mount pid when state is fresh and the process is alive', async () => { await withTempDir(async (dir) => { const statePath = await writeState(dir, { diff --git a/src/mount/relayfile-binary.ts b/src/mount/relayfile-binary.ts index 88adfcd..c15ed58 100644 --- a/src/mount/relayfile-binary.ts +++ b/src/mount/relayfile-binary.ts @@ -12,7 +12,15 @@ const NOT_FOUND_ERROR = '[factory] relayfile-mount binary not found. Install dep type MountState = { workspaceId?: unknown lastReconcileAt?: unknown + // The mount process pid. Older mounts wrote a top-level `pid`; the + // CLI-daemonized mount (`relayfile start --background`) records it under + // `daemon.pid` instead. Either may be absent. pid?: unknown + daemon?: { pid?: unknown } +} + +function coercePid(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined } function canExecute(filePath: string | undefined): filePath is string { @@ -155,9 +163,8 @@ export function checkMountStaleness( } } - const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0 - ? parsed.pid - : undefined + // Prefer the top-level pid; fall back to the CLI-daemonized mount's daemon.pid. + const pid = coercePid(parsed.pid) ?? coercePid(parsed.daemon?.pid) const lastReconcileAt = typeof parsed.lastReconcileAt === 'string' ? Date.parse(parsed.lastReconcileAt) @@ -176,7 +183,12 @@ export function checkMountStaleness( } if (pid === undefined) { - return { stale: true, reason: 'mount process pid is missing' } + // Neither a top-level pid nor daemon.pid was recorded. The fresh + // lastReconcileAt validated above is itself proof the mount is live and + // reconciling, so fall back to that rather than declaring a healthy mount + // stale and forcing a spurious refresh (which previously tore down the live + // mount and then failed to re-spawn). + return { stale: false } } try {