Skip to content

fix(connect): close TOCTOU race in connect sync lock acquisition#113

Open
omergk28 wants to merge 1 commit into
ActiveMemory:mainfrom
omergk28:fix/93-connect-sync-lock-toctou
Open

fix(connect): close TOCTOU race in connect sync lock acquisition#113
omergk28 wants to merge 1 commit into
ActiveMemory:mainfrom
omergk28:fix/93-connect-sync-lock-toctou

Conversation

@omergk28

@omergk28 omergk28 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

loadState in internal/cli/connection/core/sync guarded ctx connect sync with a stat-then-write pair — racy by construction. Two processes racing past the os.Stat could both acquire, both load the same LastSequence, and both write duplicate entries into .context/hub/.

This PR replaces the pair with the atomic create-or-fail primitive the codebase already ships for exactly this purpose: io.SafeTryLock (O_CREATE|O_EXCL, one syscall) + io.SafeUnlock, with prior art at internal/cli/dream/core/pass/pass.go:62-70. !acquired maps to the pre-existing os.ErrExist contract, so caller-facing behavior is unchanged.

Closes #93.

Changes

  • internal/cli/connection/core/sync/state.go — atomic lock acquisition; release delegates to SafeUnlock (warn-on-failure logging kept)
  • internal/config/hub/{hub.go,doc.go}LockSentinel removed: the lock is the file's existence, not its content, and the racy write was the constant's only consumer
  • internal/cli/connection/core/sync/state_test.go — new; the package previously had zero tests
  • specs/fix-connect-sync-lock-toctou.md — spec per the project's spec-per-commit invariant

Tests (mapped to the issue's Tests Required)

Issue ask Test
N goroutines, exactly one succeeds, N−1 get os.ErrExist TestLoadState_RejectsConcurrentSyncs (16-way; winners held until all results are collected, so the count is deterministic)
Lock released after failure so the next attempt proceeds TestLoadState_ReleaseRemovesLock + TestLoadState_ReleasesLockOnCorruptState (post-acquisition failure must not leak the lock)
Lock lives at <ctxDir>/hub/.sync.lock TestLoadState_LockFileLocation

Verification

  • Mutation check: the new contention test was run against the old stat-then-write code — it fails with winners = 16, want exactly 1, proving both that the race is real and that the test catches it. Against the fix: exactly 1 winner, 15 × os.ErrExist.
  • go test -race -count=10 ./internal/cli/connection/core/sync/ — clean.
  • Full CI suite green (fmt, vet, golangci-lint, style, drift, docs, full test suite, shellcheck).

Notes

🤖 Generated with Claude Code

The sync lock was a stat-then-write: two processes racing past the
existence check could both acquire, both load the same LastSequence,
and both write duplicate entries into .context/hub/. Replace the
pair with the atomic io.SafeTryLock (O_CREATE|O_EXCL, single syscall)
and release via io.SafeUnlock — the same primitive the dream pass
already uses, preserving the os.ErrExist contract for callers.

- LockSentinel removed: the lock is the file's existence, not its
  content, and the racy write was the constant's only consumer.
- state_test.go regression-pins the contract: 16-way contention
  yields exactly one winner, release frees the next sync, a corrupt
  state file does not leak the lock, and the lock path stays at
  <ctxDir>/hub/.sync.lock.

Closes ActiveMemory#93.

Spec: specs/fix-connect-sync-lock-toctou.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Omer Kocaoglu <omergk28@gmail.com>
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.

ctx connect sync lock has a TOCTOU race; concurrent syncs can both pass the check

1 participant