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

  1. Use withLiveArtifactRefreshLock() which acquires and always releases the lock in a finally block.
  2. If the lock is genuinely stale (older than DEFAULT_LIVE_ARTIFACT_TOTAL_TIMEOUT_MS), call recoverStaleLiveArtifactRefreshes() to reap it.
  3. Manually delete <artifactDir>/refresh.lock.json only after confirming no refresh is actually running.
  4. 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

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


AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12). Data as JSON: /api/errors/d895fd7cf9411c55. Report an issue: GitHub.