nexu-io/open-design · error · LiveArtifactRefreshLockError
live artifact refresh already active
Error message
live artifact refresh already active
What it means
Thrown as LiveArtifactRefreshLockError by acquireLiveArtifactRefreshLock() when writing refresh.lock.json with flag 'wx' (exclusive create) fails with EEXIST. The lock file already exists, meaning another refresh is already running for that artifact. This is a concurrency guard: only one refresh may run at a time per artifact. The custom error class carries projectId, artifactId, and lockPath so callers can surface scope.
Source
Thrown at apps/daemon/src/live-artifacts/store.ts:800
const paths = await assertLiveArtifactRefreshLockScope(options.projectsRoot, options.projectId, artifactId);
const state = await readLiveArtifactRefreshState(paths, options.projectId, artifactId);
const refreshOrdinal = state.nextRefreshOrdinal;
const refreshId = formatRefreshId(refreshOrdinal);
const metadata: LiveArtifactRefreshLockMetadata = {
schemaVersion: 1,
projectId: options.projectId,
artifactId,
refreshId,
refreshOrdinal,
acquiredAt: (options.now ?? new Date()).toISOString(),
lockId: randomBytes(12).toString('hex'),
};
try {
await writeFile(paths.refreshLockPath, stableJson(metadata), { encoding: 'utf8', flag: 'wx' });
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
throw new LiveArtifactRefreshLockError('live artifact refresh already active', {
projectId: options.projectId,
artifactId,
lockPath: paths.refreshLockPath,
});
}
throw error;
}
try {
await writeLiveArtifactRefreshState(paths, {
...state,
nextRefreshOrdinal: refreshOrdinal + 1,
});
} catch (error) {
await rm(paths.refreshLockPath, { force: true });
throw error;
}
View on GitHub (pinned to 5be4028344)
Solutions
- Use withLiveArtifactRefreshLock() which acquires and always releases the lock in a finally block.
- If the lock is genuinely stale (older than DEFAULT_LIVE_ARTIFACT_TOTAL_TIMEOUT_MS), call recoverStaleLiveArtifactRefreshes() to reap it.
- Manually delete <artifactDir>/refresh.lock.json only after confirming no refresh is actually running.
- Serialize refresh requests per-artifact in the calling layer (queue or mutex) to avoid racing the exclusive-create.
Example fix
// before
const lock = await acquireLiveArtifactRefreshLock({ projectsRoot, projectId, artifactId });
// ...work... (crash here leaves the lock)
// after — guaranteed release
await withLiveArtifactRefreshLock(
{ projectsRoot, projectId, artifactId },
async (lock) => { /* refresh work; lock released in finally */ },
); Defensive patterns
Strategy: try-catch
Validate before calling
import { stat } from 'node:fs/promises';
async function isRefreshActive(paths): Promise<boolean> {
try { await stat(paths.refreshLockPath); return true; } catch { return false; }
}
if (await isRefreshActive(paths)) {
// surface 'refresh already in progress' to the user instead of attempting acquire
} Type guard
import { LiveArtifactRefreshLockError } from './store';
function isRefreshLockBusy(error: unknown): boolean {
return error instanceof LiveArtifactRefreshLockError;
} Try / catch
import { acquireLiveArtifactRefreshLock, LiveArtifactRefreshLockError, recoverStaleLiveArtifactRefreshes } from './store';
try {
const lock = await acquireLiveArtifactRefreshLock({ projectsRoot, projectId, artifactId });
// ... refresh work ...
} catch (error) {
if (error instanceof LiveArtifactRefreshLockError) {
// Surface 'refresh already in progress' to the user; optionally recover stale locks
await recoverStaleLiveArtifactRefreshes({ projectsRoot });
return;
}
throw error;
} Prevention
- Always acquire through withLiveArtifactRefreshLock so release happens in finally.
- Run recoverStaleLiveArtifactRefreshes on daemon startup to reap crashed locks.
- Serialize refresh requests per artifact in the calling layer.
When it happens
Trigger: Two concurrent calls to acquireLiveArtifactRefreshLock for the same artifact (e.g. two browser tabs, or the UI and a scheduled refresh racing); a previous refresh crashed or timed out without releasing its lock, leaving refresh.lock.json behind; a refresh is still genuinely in progress.
Common situations: Daemon restart after a crash that left a stale lock; user double-clicks a 'refresh' button; automation fires a refresh while another is mid-flight; long-running refresh whose worker died silently.
Related errors
- live artifact refresh is older than the latest committed ref
- REFRESH_LOCKED
- connector refresh output must be a JSON object
- refresh source ${source.type} is not supported yet
- No refresh source is available yet.
AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12).
Data as JSON: /api/errors/d895fd7cf9411c55.
Report an issue: GitHub.