Yeachan-Heo/oh-my-codex · error · QuestionSubmitError

question_not_open

question_not_open

Error message

Timed out acquiring submit lock for ${recordPath}

What it means

withQuestionSubmitLock acquires a directory-based lock (mkdir, recovering stale locks on EEXIST). If the lock dir still exists after the deadline and cannot be recovered as stale, it throws QuestionSubmitError with code question_not_open. This guards a record against concurrent submissions.

Source

Thrown at src/question/state.ts:277

  const ownerToken = lockOwnerToken();
  const deadline = Date.now() + QUESTION_SUBMIT_LOCK_TIMEOUT_MS;
  await mkdir(dirname(lockDir), { recursive: true });
  while (true) {
    try {
      await mkdir(lockDir);
      try {
        await writeFile(ownerPath, ownerToken, 'utf8');
      } catch (error) {
        await rm(lockDir, { recursive: true, force: true });
        throw error;
      }
      break;
    } catch (error) {
      const err = error as NodeJS.ErrnoException;
      if (err.code !== 'EEXIST') throw error;
      if (await maybeRecoverStaleQuestionLock(lockDir)) continue;
      if (Date.now() > deadline) {
        throw new QuestionSubmitError('question_not_open', `Timed out acquiring submit lock for ${recordPath}`);
      }
      await sleep(25);
    }
  }

  try {
    return await fn();
  } finally {
    try {
      const currentOwner = await readFile(ownerPath, 'utf8');
      if (currentOwner.trim() === ownerToken) {
        await rm(lockDir, { recursive: true, force: true });
      }
    } catch {
    }
  }
}

View on GitHub (pinned to 3ad79a8a6f)

Solutions

  1. Check whether a stale lock directory remains at the lock path and remove it if its owner process is dead
  2. Prevent duplicate submissions upstream (debounce/disable submit button while a request is in flight)
  3. Increase the lock timeout if updates legitimately take long
  4. If a stale-lock PID check is failing due to PID reuse, clear the lock manually or restart the offending process

Example fix

// before
await withQuestionSubmitLock(recordPath, () => submit(recordPath, answers));
// after
// debounce: only one in-flight submit per record
let submitting = false;
if (submitting) return;
submitting = true;
try { await withQuestionSubmitLock(recordPath, () => submit(recordPath, answers)); }
finally { submitting = false; }
Defensive patterns

Strategy: retry

Validate before calling

// before submit, check for an obviously stale lock dir owned by a dead process
await maybeClearDeadLock(lockDirFor(recordPath));

Try / catch

try { await withQuestionSubmitLock(p, fn); } catch (e) { if ((e as QuestionSubmitError).code === 'question_not_open') { await maybeClearDeadLock(lockDirFor(p)); return withQuestionSubmitLock(p, fn); // one retry } throw e; }

Prevention

When it happens

Trigger: Two callers submit/mark prompting on the same recordPath simultaneously and the loser waits past the timeout; or a crashed process left a lock directory whose mtime/owner info prevents maybeRecoverStaleQuestionLock from classifying it stale.

Common situations: Duplicate submit events (double-click, retry storm); a killed process leaving a stale lock the recovery heuristic (e.g. PID liveness check) won't reclaim because the PID was reused; NFS or container filesystems where stale-lock detection fails; overly long-held locks during slow writes.

Understand the failure class

Related errors


AI-assisted analysis of Yeachan-Heo/oh-my-codex@3ad79a8a6f (2026-08-27). Data as JSON: /api/errors/5f09d947e793502a. Report an issue: GitHub.