/** * Shared async file-mutex for serializing create-persist critical sections. * * Extracted from the near-identical `withCrawlLock` (firecrawl) and * `withResearchLock` (parallel) into a single reusable helper. Any * long-running capability that POSTs to create a server-side job then * persists the job ID should wrap the create-persist sequence in this lock * so concurrent identical invocations serialize — the second caller waits, * then re-reads the state file and finds the first's persisted job ID * instead of creating (and billing) a second job. * * The lock uses an exclusive `wx`-create lockfile sibling to the state * file. A stale lock (holder crashed without releasing) is broken after * `staleMs`. While the critical section runs, the lock mtime is refreshed * periodically (issue #46/#48a) so a slow-but-live holder is never * displaced by the staleMs check. * * When `stateDir` is undefined (in-memory test mode), the lock is a no-op — * tests are single-process and need no cross-process serialization. */ /** * Default lock timing constants. Consumers that need the standard values * (30s acquire timeout, 10-min stale threshold) should import these * instead of duplicating magic numbers. */ export declare const DEFAULT_LOCK_TIMEOUT_MS = 30000; export declare const DEFAULT_LOCK_STALE_MS: number; /** * Options for {@link withAsyncFileLock}. */ export interface AsyncFileLockOptions { /** How long to wait for a contended lock before giving up (ms). */ readonly timeoutMs: number; /** A lock older than this is treated as stale and broken (ms). */ readonly staleMs: number; /** * Injectable timer (tests pass a capped timer so lock-acquire retry * loops resolve faster than the production 500ms sleep). */ readonly setTimeout?: typeof setTimeout; /** Label used in the timeout error message (e.g. "Firecrawl crawl"). */ readonly timeoutLabel: string; /** * Cooperative-cancellation signal (issue #47). When aborted — before * the call or during the lock wait — the pending wait rejects promptly * with a {@link LockTimeoutError} instead of sleeping through to the * acquire deadline. A live critical section is NOT interrupted: the * holder's `fn` owns its own signal handling. */ readonly signal?: AbortSignal; } /** * Lock-acquire failure: the acquire deadline passed, or the caller's * signal aborted the wait (issue #47). Deliberately NOT a `TimeoutError` * and deliberately free of the substrings "timeout"/"timed out"/ * "etimedout": provider error normalizers (`normalizeFirecrawlError`, * `normalizeTavilyError`, ...) classify unknown errors whose message * contains "timed out" as request `TimeoutError`s and attach * request-timeout guidance (`Z_AI_TIMEOUT` & co.) that cannot influence * this fixed lock deadline (issue #48). Consumers that need a * provider-specific envelope detect this class via `instanceof`. */ export declare class LockTimeoutError extends Error { /** The lock's `timeoutLabel` ("Firecrawl crawl", "Cache prune", ...). */ readonly label: string; constructor(label: string, detail: string); } /** * Ownership-safe stale-lock break (review fixup): unlink the lock at * `lockPath` ONLY if it still refers to the exact file that was statted * as stale — the statted `ino`/`dev`/`mtimeMs` triple. A naked * `fs.unlink(lockPath)` can race a second waiter that already broke the * same stale lock and re-acquired it: the newcomer's fresh lock would be * destroyed and two holders end up inside the critical section. The * re-stat narrows the race to the unavoidable unlink-by-name window * (Node has no unlink-by-inode); the statted-inode verification is what * keeps the common crash-recovery path from destroying a live * successor's lock. * * `mtimeMs` is part of the identity triple because some filesystems * (notably overlayfs on CI runners) REUSE inode numbers aggressively: an * unlinked lock immediately recreated by a successor can receive the * same ino+dev, defeating the inode guard alone (observed on GitHub * Actions 2026-09-10: the successor's lock was unlinked and the test * read ENOENT). A successor is always written at or after the stale * lock's mtime, so an mtime newer than the statted one means "not ours". * * Exported for tests (they must be able to pin the not-ours-don't-touch * branch without staging a real multi-process race). */ export declare function breakStaleLock(lockPath: string, statted: { readonly ino: number; readonly dev: number; readonly mtimeMs: number; }): Promise; /** * Serialize a critical section via an exclusive lockfile. * * Creates `{stateDir}/{identityHash}.lock` with `wx` (exclusive create). * If the lock is held, retries with jittered exponential backoff until acquired or * `timeoutMs` elapses. A lock older than `staleMs` is broken (unlinked) and retried — * but only when its mtime is genuinely stale: while `fn()` runs, the * holder refreshes the lock mtime (issue #46/#48a), so a live holder is * never displaced no matter how long the critical section takes. * * When `stateDir` is undefined, the lock is a no-op — `fn()` runs directly. * * @example * ```ts * const jobId = await withAsyncFileLock( * stateDir, * identityHash, * async () => createAndPersistJob(), * { timeoutMs: 30_000, staleMs: 600_000, timeoutLabel: "Tavily research" }, * ); * ``` */ export declare function withAsyncFileLock(stateDir: string | undefined, identityHash: string, fn: () => Promise, options: AsyncFileLockOptions): Promise; //# sourceMappingURL=async-file-lock.d.ts.map