Skip to content

fix(state): tolerate concurrent creation of state directories - #35

Merged
yuezengwu merged 1 commit into
mainfrom
fix/state-directory-race
Nov 4, 2026
Merged

yuezengwu merged 1 commit into
mainfrom
fix/state-directory-race

Conversation

@liuchao-001

Copy link
Copy Markdown
Contributor

Problem

privateDirectory checks whether a state directory exists and then calls mkdirSync without recursive. When two CLI processes start on a fresh home at the same time, both see ~/.context-tree (or repositories/, snapshots/) as absent, and the second mkdir fails with EEXIST. That source's sync then fails with CONTEXT_TREE_FAILED.

OpenTag hits this now that its command gateway syncs each selected source in its own concurrent CLI child (first-tree-ai/opentag#1287). On a fresh home with two sources from one repository, one source fails with:

EEXIST: file already exists, mkdir '<home>/.context-tree'

The lock wait from #30 handles a warm home; this race happens before any lock exists.

Change

  • mkdirUnlessExists treats EEXIST as success. privateDirectory then validates the entry that won the race with the same realDirectoryWithoutSymlinks check, so a symlink or a non-directory is still rejected.
  • The Skill install directory helper (ensureRealDirectory) gets the same fix and re-runs its existing symlink and directory check.

Tests

New tests/state-directory-race.test.ts makes lstatSync report a path as absent once, as if another process created it right after the check:

  • an existing directory is accepted (without the fix this fails with EEXIST);
  • a symlink placed there is still rejected (without the fix this also fails with EEXIST).

pnpm check, pnpm typecheck, and pnpm test (15 files, 130 tests) pass.

Release PR #34 (1.1.1) is open. It would be good to land this first so the release includes it; OpenTag #1287 will pin the release that does.

🤖 Generated with Claude Code

Two CLI processes starting on a fresh home both saw ~/.context-tree (or
repositories/, snapshots/) as absent and raced to mkdir it; the loser
failed its sync with EEXIST. Creation now treats EEXIST as success and
still validates the winning entry, so symlinks and non-directories are
rejected as before. The Skill install directory helper gets the same fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@liuchao-001
liuchao-001 requested a review from yuezengwu November 4, 2026 19:15
@liuchao-001 liuchao-001 mentioned this pull request Nov 4, 2026

@yuezengwu yuezengwu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independently reviewed exact head 6dad8bfd01a291c0237156378ec74757ac600baa against actual main a089f6996b7435d7f3eb8d1f5fb8e54fe62e8c72. The mkdir race fix tolerates only EEXIST and then validates the winning entry with the existing real-directory/symlink checks on both state and Skill-directory paths. Other filesystem errors still propagate. No schema, lock-timeout, or credential contract changes.

Frozen install (host-install scripts disabled), repository check, typecheck,130 tests across15 files, and real-tarball check:package pass. The new deterministic test covers a concurrent real-directory winner and rejects a symlink winner; this is controlled local validation, not a live multi-process stress or release. No blocker found. Version/tag/npm publication remain separate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants