From 56bffd9a42f1521e57be5a0bd8607282b8be52b6 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 19 Jun 2026 11:37:49 +0200 Subject: [PATCH] fix(factory): confirm a freshly-started CLI mount via daemon.pid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #12. That PR fixed `checkMountStaleness` to honor `daemon.pid`, but `waitForStateFile`/`isValidMountState` (the post-spawn readiness check) still required a top-level `pid`. A CLI-daemonized mount (`relayfile start --background`) records its pid under `daemon.pid`, so after the factory spawns the mount the validator rejected the freshly-written state.json and timed out: `relayfile mount did not become ready within 10000ms (state file is malformed…)`, leaving the factory to warn "writeback may not propagate" even though the mount came up fine. Export `coercePid` and use `coercePid(state.pid) ?? coercePid(state.daemon?.pid)` in `isValidMountState`, matching `checkMountStaleness`. 526 tests green; build clean. Co-Authored-By: Claude Opus 4.8 --- src/mount/local-mount-preflight.test.ts | 37 +++++++++++++++++++++++++ src/mount/local-mount-preflight.ts | 12 ++++---- src/mount/relayfile-binary.ts | 2 +- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/mount/local-mount-preflight.test.ts b/src/mount/local-mount-preflight.test.ts index 44985b6..d0c9c6b 100644 --- a/src/mount/local-mount-preflight.test.ts +++ b/src/mount/local-mount-preflight.test.ts @@ -231,6 +231,43 @@ describe('ensureLocalMount', () => { }) }) + it('confirms a CLI mount that records its pid under daemon.pid (not top-level pid)', async () => { + await withTempDir(async (dir) => { + const cli = join(dir, 'relayfile') + await writeFile(cli, '#!/bin/sh\n', 'utf8') + await chmod(cli, 0o755) + resolveCliMock.mockReturnValue(cli) + const stateDir = join(dir, '.integrations', '.relay') + const statePath = join(stateDir, 'state.json') + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + const child = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + child.stderr = new EventEmitter() + const isStart = args[0] === 'start' + setTimeout(() => { + const finish = async (): Promise => { + if (isStart) { + await mkdir(stateDir, { recursive: true }) + // CLI-daemonized mount: pid lives under daemon.pid, no top-level pid. + await writeFile(statePath, JSON.stringify({ + workspaceId: 'rw_test', + lastReconcileAt: new Date().toISOString(), + daemon: { pid: process.pid }, + }), 'utf8') + } + } + void finish().then(() => child.emit('close', 0), () => child.emit('close', 1)) + }, 0) + return child + }) + + await expect(ensureLocalMount('rw_test', dir, { + stateWaitTimeoutMs: 100, + stateWaitPollMs: 1, + })).resolves.toBeUndefined() + }) + }) + it('rejects a malformed state file instead of silently continuing', async () => { await withTempDir(async (dir) => { await installFakeBinary(dir) diff --git a/src/mount/local-mount-preflight.ts b/src/mount/local-mount-preflight.ts index d364a1d..beb97d0 100644 --- a/src/mount/local-mount-preflight.ts +++ b/src/mount/local-mount-preflight.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { spawn } from 'node:child_process' -import { checkMountStaleness, resolveRelayfileCli, resolveRelayfileMountBinary } from './relayfile-binary' +import { checkMountStaleness, coercePid, resolveRelayfileCli, resolveRelayfileMountBinary } from './relayfile-binary' const STATE_FILE = '.integrations/.relay/state.json' @@ -175,14 +175,16 @@ function isValidMountState( acceptableWorkspaceIds: readonly string[] = [], ): boolean { if (value === null || typeof value !== 'object' || Array.isArray(value)) return false - const state = value as { workspaceId?: unknown; lastReconcileAt?: unknown; pid?: unknown } + const state = value as { workspaceId?: unknown; lastReconcileAt?: unknown; pid?: unknown; daemon?: { pid?: unknown } } const accepted = new Set([workspaceId, ...acceptableWorkspaceIds]) + // A CLI-daemonized mount (`relayfile start --background`) records its pid under + // `daemon.pid`, not the top-level `pid` — accept either, matching + // checkMountStaleness (else a freshly-started CLI mount is never confirmed ready). + const pid = coercePid(state.pid) ?? coercePid(state.daemon?.pid) return typeof state.workspaceId === 'string' && accepted.has(state.workspaceId) && typeof state.lastReconcileAt === 'string' && Number.isFinite(Date.parse(state.lastReconcileAt)) && - typeof state.pid === 'number' && - Number.isInteger(state.pid) && - state.pid > 0 + pid !== undefined } const isAuthError = (stderr: string): boolean => diff --git a/src/mount/relayfile-binary.ts b/src/mount/relayfile-binary.ts index c15ed58..1ff31b7 100644 --- a/src/mount/relayfile-binary.ts +++ b/src/mount/relayfile-binary.ts @@ -19,7 +19,7 @@ type MountState = { daemon?: { pid?: unknown } } -function coercePid(value: unknown): number | undefined { +export function coercePid(value: unknown): number | undefined { return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined }