Skip to content

Workspace initialization fails on first run due to missing parent directory #4

@dbeckham

Description

@dbeckham

Workspace initialization fails on first run with a "lock timeout" error because withLock() attempts to create a lock directory without ensuring its parent directory exists. This affects all versions since MCP v2 (commit ba1ae92, Nov 11 2025).

Affected file: src/lib/workspace/locks.ts

This issue was discovered while investigating "lock timeout" errors during script execution. The error message is misleading, as it suggests lock contention when the actual problem is a missing directory causing ENOENT errors misinterpreted as "lock already exists."

Root Cause

The lock mechanism depends on a directory created by the code it protects:

  1. withLock() creates lock at: envPaths("datocms-mcp").data/workspace-init.lock.lock
  2. Parent directory: envPaths("datocms-mcp").data (e.g., ~/Library/Application Support/datocms-mcp-nodejs/)
  3. withLock() calls fs.mkdir(lockDir) without { recursive: true }
  4. When parent doesn't exist, mkdir throws ENOENT
  5. Error is caught and misinterpreted as "lock already exists" (line 28)
  6. Infinite retry until timeout (~2 minutes)

The workspace root at src/lib/workspace/index.ts:79 creates this same directory with fs.mkdir(this.rootPath, { recursive: true }), but this runs inside the lock-protected function. The lock's parent directory is created by the code the lock protects.

Details

1. No code path creates parent before lock

The lock's parent directory is never created before lock acquisition. Here are all mkdir calls in the code:

// src/lib/workspace/locks.ts:19
await fs.mkdir(lockDir);  // No recursive option, no parent creation

// src/lib/workspace/index.ts:79 (Inside withLock)
await fs.mkdir(this.rootPath, { recursive: true });  // Runs after lock acquisition

// src/lib/workspace/index.ts:80 (Inside withLock)
await fs.mkdir(this.scriptsPath, { recursive: true });

// src/lib/workspace/temp.ts:29
await fs.mkdir(scriptsDir, { recursive: true });  // Creates different path, not lock parent

2. Bug introduced in MCP v2

The locks.ts file was introduced in commit ba1ae92 (Nov 11, 2025).

To Reproduce

  1. Ensure workspace directory doesn't exist:

    # macOS
    rm -rf ~/Library/Application\ Support/datocms-mcp-nodejs/
    
    # Linux
    rm -rf ~/.local/share/datocms-mcp-nodejs/
    
    # Windows
    # Remove %LOCALAPPDATA%\datocms-mcp-nodejs\
  2. Run any MCP operation that requires workspace initialization:

    {
      "tool": "DatoCMS-create_script",
      "arguments": {
        "name": "script://test.ts",
        "content": "import { type Client } from '@datocms/cma-client-node';\nexport default async function(client: Client) { console.log('test'); }",
        "execute": true
      }
    }
  3. Error after ~2 minutes:

    Error: lock timeout
    

Simple Fix

Adding parent directory creation before lock acquisition resolves the immediate issue. This simple fix works as an example, but doesn't address the problem from an architectural standpoint, including the ENOENT error being misinterpreted as the lock already existing:

// src/lib/workspace/locks.ts
export async function withLock<T>(
  name: string,
  fn: () => Promise<T>,
  opts?: { timeoutMs?: number },
): Promise<T> {
  const lockDir = path.join(envPaths("datocms-mcp").data, `${name}.lock`);
  const timeoutMs = opts?.timeoutMs ?? 2 * 60 * 1000;
  const start = Date.now();

  // FIX: Ensure parent directory exists before attempting lock acquisition
  await fs.mkdir(path.dirname(lockDir), { recursive: true });

  while (true) {
    try {
      await fs.mkdir(lockDir);
      // ... rest of function

Test Case

test("should initialize workspace even if parent directory doesn't exist", async () => {
  const testRoot = path.join(os.tmpdir(), `test-workspace-${Date.now()}`);
  
  // Ensure parent doesn't exist
  await fs.rm(testRoot, { recursive: true, force: true });
  
  const wm = new Workspace({ rootPath: testRoot });
  await wm.ensure();
  
  // Verify workspace was created
  expect(await fs.access(testRoot).then(() => true, () => false)).toBe(true);
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions