mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Fix Windows path identity and lock layering
Problem: Windows native tests failed every identity-aware file lock with ERROR_NOACCESS, while the first path-identity integration also exceeded repository size, complexity, and layering limits. Root cause: The Windows implementation queried FileCaseSensitiveInfo through GetFileInformationByHandleEx, which is rejected on the CI runner. The utility-layer filelock package also imported the higher-level pathidentity package directly, and several identity workflows remained embedded in oversized owners. Fix: Query per-directory case sensitivity with NtQueryInformationFile, preserving explicit unsupported-information fallbacks. Add an identitylock composition layer that resolves access paths and passes comparison keys into filelock's explicit local-queue API, then migrate production file locks to it. Keep custom session, repair, and workspace lock protocols on explicit keys, and extract path and acquisition logic into focused owner files. Verification: - go run ./tools/repolint - go test -run '^$' ./... - go test -race ./internal/filelock ./internal/identitylock ./internal/workspacelease - Windows amd64 cross-compile for affected root packages
This commit is contained in:
1 parent
f6a846c070
commit
08ca31641a
51 files changed
+741
-462
No files matched your search
@@ -14,8 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const desktopProjectsFileLockTimeout = 2 * time.Second
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/permissionpreset"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
func (e *HeartbeatEngine) readConfigSnapshot() (heartbeatConfigSnapshot, error) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const operationColumns = `id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision,execution_json`
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const SchemaVersion = 1
|
||||
|
||||
@@ -3,8 +3,6 @@ package workspacestate
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,9 +13,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
"reasonix/internal/pathidentity"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -109,109 +106,6 @@ func (s *Store) Load(ctx context.Context) (State, error) {
|
||||
return cloneState(state)
|
||||
}
|
||||
|
||||
func (s *Store) EnsureWorkspace(ctx context.Context, workspace Workspace) error {
|
||||
_, err := s.EnsureWorkspaceResolved(ctx, workspace)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureWorkspaceResolved registers workspace or returns the authoritative ID
|
||||
// of an existing workspace with the same physical directory identity.
|
||||
func (s *Store) EnsureWorkspaceResolved(ctx context.Context, workspace Workspace) (string, error) {
|
||||
workspace.ID = strings.TrimSpace(workspace.ID)
|
||||
if workspace.ID == "" {
|
||||
return "", errors.New("workspace id is required")
|
||||
}
|
||||
resolvedID := workspace.ID
|
||||
err := s.mutate(ctx, func(state *State) error {
|
||||
var candidate pathidentity.Identity
|
||||
var resolveErr error
|
||||
if strings.TrimSpace(workspace.Root) != "" {
|
||||
candidate, resolveErr = pathidentity.Resolve(workspace.Root, pathidentity.Options{FollowLeaf: true})
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve workspace root: %w", resolveErr)
|
||||
}
|
||||
}
|
||||
revalidateCandidate := func() error {
|
||||
if candidate.Key == "" {
|
||||
return nil
|
||||
}
|
||||
latest, latestErr := pathidentity.Resolve(workspace.Root, pathidentity.Options{FollowLeaf: true})
|
||||
if latestErr != nil {
|
||||
return fmt.Errorf("revalidate workspace root: %w", latestErr)
|
||||
}
|
||||
if latest.Key != candidate.Key {
|
||||
return fmt.Errorf("%w: workspace root identity changed during registration", ErrMutationConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
matches := make([]string, 0, 1)
|
||||
if candidate.Key != "" {
|
||||
for id, existing := range state.Workspaces {
|
||||
if strings.TrimSpace(existing.Root) == "" {
|
||||
continue
|
||||
}
|
||||
identity, resolveErr := pathidentity.Resolve(existing.Root, pathidentity.Options{FollowLeaf: true})
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve persisted workspace %q: %w", id, resolveErr)
|
||||
}
|
||||
if identity.Key == candidate.Key {
|
||||
matches = append(matches, id)
|
||||
}
|
||||
}
|
||||
slices.Sort(matches)
|
||||
if len(matches) > 1 {
|
||||
return fmt.Errorf("%w: %s", ErrAmbiguousIdentity, strings.Join(matches, ", "))
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
if err := revalidateCandidate(); err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedID = matches[0]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
current, exists := state.Workspaces[workspace.ID]
|
||||
if exists {
|
||||
if current.Root == workspace.Root || (current.Root == "" && workspace.Root == "") {
|
||||
return revalidateCandidate()
|
||||
}
|
||||
if workspace.ID == GlobalWorkspaceID || candidate.Key == "" {
|
||||
return ErrMutationConflict
|
||||
}
|
||||
resolvedID = versionedWorkspaceID(candidate.Key)
|
||||
if fallback, fallbackExists := state.Workspaces[resolvedID]; fallbackExists {
|
||||
identity, resolveErr := pathidentity.Resolve(fallback.Root, pathidentity.Options{FollowLeaf: true})
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve collided workspace %q: %w", resolvedID, resolveErr)
|
||||
}
|
||||
if identity.Key != candidate.Key {
|
||||
return ErrMutationConflict
|
||||
}
|
||||
if err := revalidateCandidate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := revalidateCandidate(); err != nil {
|
||||
return err
|
||||
}
|
||||
workspace.ID = resolvedID
|
||||
workspace.SessionIDs = []string{}
|
||||
workspace.CreatedAt, workspace.UpdatedAt = now, now
|
||||
state.Workspaces[workspace.ID] = workspace
|
||||
state.WorkspaceIDs = append(state.WorkspaceIDs, workspace.ID)
|
||||
return nil
|
||||
})
|
||||
return resolvedID, err
|
||||
}
|
||||
|
||||
func versionedWorkspaceID(identityKey string) string {
|
||||
sum := sha256.Sum256([]byte("reasonix-workspace-pathidentity-v2\x00" + identityKey))
|
||||
return "project-v2-" + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func (s *Store) RenameWorkspace(ctx context.Context, workspaceID, title string) error {
|
||||
return s.mutate(ctx, func(state *State) error {
|
||||
workspace, ok := state.Workspaces[strings.TrimSpace(workspaceID)]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package workspacestate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/pathidentity"
|
||||
)
|
||||
|
||||
func (s *Store) EnsureWorkspace(ctx context.Context, workspace Workspace) error {
|
||||
_, err := s.EnsureWorkspaceResolved(ctx, workspace)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureWorkspaceResolved registers workspace or returns the authoritative ID
|
||||
// of an existing workspace with the same physical directory identity.
|
||||
func (s *Store) EnsureWorkspaceResolved(ctx context.Context, workspace Workspace) (string, error) {
|
||||
workspace.ID = strings.TrimSpace(workspace.ID)
|
||||
if workspace.ID == "" {
|
||||
return "", errors.New("workspace id is required")
|
||||
}
|
||||
resolvedID := workspace.ID
|
||||
err := s.mutate(ctx, func(state *State) error {
|
||||
var candidate pathidentity.Identity
|
||||
var resolveErr error
|
||||
if strings.TrimSpace(workspace.Root) != "" {
|
||||
candidate, resolveErr = pathidentity.Resolve(workspace.Root, pathidentity.Options{FollowLeaf: true})
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve workspace root: %w", resolveErr)
|
||||
}
|
||||
}
|
||||
revalidateCandidate := func() error {
|
||||
if candidate.Key == "" {
|
||||
return nil
|
||||
}
|
||||
latest, latestErr := pathidentity.Resolve(workspace.Root, pathidentity.Options{FollowLeaf: true})
|
||||
if latestErr != nil {
|
||||
return fmt.Errorf("revalidate workspace root: %w", latestErr)
|
||||
}
|
||||
if latest.Key != candidate.Key {
|
||||
return fmt.Errorf("%w: workspace root identity changed during registration", ErrMutationConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
matches := make([]string, 0, 1)
|
||||
if candidate.Key != "" {
|
||||
for id, existing := range state.Workspaces {
|
||||
if strings.TrimSpace(existing.Root) == "" {
|
||||
continue
|
||||
}
|
||||
identity, resolveErr := pathidentity.Resolve(existing.Root, pathidentity.Options{FollowLeaf: true})
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve persisted workspace %q: %w", id, resolveErr)
|
||||
}
|
||||
if identity.Key == candidate.Key {
|
||||
matches = append(matches, id)
|
||||
}
|
||||
}
|
||||
slices.Sort(matches)
|
||||
if len(matches) > 1 {
|
||||
return fmt.Errorf("%w: %s", ErrAmbiguousIdentity, strings.Join(matches, ", "))
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
if err := revalidateCandidate(); err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedID = matches[0]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
current, exists := state.Workspaces[workspace.ID]
|
||||
if exists {
|
||||
if current.Root == workspace.Root || (current.Root == "" && workspace.Root == "") {
|
||||
return revalidateCandidate()
|
||||
}
|
||||
if workspace.ID == GlobalWorkspaceID || candidate.Key == "" {
|
||||
return ErrMutationConflict
|
||||
}
|
||||
resolvedID = versionedWorkspaceID(candidate.Key)
|
||||
if fallback, fallbackExists := state.Workspaces[resolvedID]; fallbackExists {
|
||||
identity, resolveErr := pathidentity.Resolve(fallback.Root, pathidentity.Options{FollowLeaf: true})
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve collided workspace %q: %w", resolvedID, resolveErr)
|
||||
}
|
||||
if identity.Key != candidate.Key {
|
||||
return ErrMutationConflict
|
||||
}
|
||||
if err := revalidateCandidate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := revalidateCandidate(); err != nil {
|
||||
return err
|
||||
}
|
||||
workspace.ID = resolvedID
|
||||
workspace.SessionIDs = []string{}
|
||||
workspace.CreatedAt, workspace.UpdatedAt = now, now
|
||||
state.Workspaces[workspace.ID] = workspace
|
||||
state.WorkspaceIDs = append(state.WorkspaceIDs, workspace.ID)
|
||||
return nil
|
||||
})
|
||||
return resolvedID, err
|
||||
}
|
||||
|
||||
func versionedWorkspaceID(identityKey string) string {
|
||||
sum := sha256.Sum256([]byte("reasonix-workspace-pathidentity-v2\x00" + identityKey))
|
||||
return "project-v2-" + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/pathidentity"
|
||||
)
|
||||
|
||||
func cleanDesktopPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
path = abs
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func sameDesktopPath(a, b string) bool {
|
||||
same, err := sameDesktopPathStrict(a, b)
|
||||
return err == nil && same
|
||||
}
|
||||
|
||||
func sameDesktopPathStrict(a, b string) (bool, error) {
|
||||
a, b = cleanDesktopPath(a), cleanDesktopPath(b)
|
||||
if a == "" || b == "" {
|
||||
return false, &pathidentity.Error{Kind: pathidentity.ErrorInvalid, Stage: "input", Err: errors.New("desktop path is empty")}
|
||||
}
|
||||
return pathidentity.Same(a, b, pathidentity.Options{FollowLeaf: true})
|
||||
}
|
||||
|
||||
func projectRootKey(root string) string {
|
||||
root = cleanDesktopPath(root)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
identity, err := pathidentity.Resolve(root, pathidentity.Options{FollowLeaf: true})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return identity.Key
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
// The sibling staging files remain inode witnesses until publication is marked
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"reasonix/desktop/internal/legacycleanup"
|
||||
"reasonix/desktop/internal/workspacestate"
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"reasonix/internal/extension/providerext"
|
||||
"reasonix/internal/fileutil"
|
||||
"reasonix/internal/notify"
|
||||
"reasonix/internal/pathidentity"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/session"
|
||||
"reasonix/internal/sessiontitle"
|
||||
@@ -6106,45 +6105,6 @@ func legacySessionScopeMatchesMigrationTarget(meta agent.BranchMeta, scope, work
|
||||
return metaRoot == "" || sameProjectRoot(globalWorkspaceRoot(), metaRoot)
|
||||
}
|
||||
|
||||
func cleanDesktopPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
path = abs
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func sameDesktopPath(a, b string) bool {
|
||||
same, err := sameDesktopPathStrict(a, b)
|
||||
return err == nil && same
|
||||
}
|
||||
|
||||
func sameDesktopPathStrict(a, b string) (bool, error) {
|
||||
a, b = cleanDesktopPath(a), cleanDesktopPath(b)
|
||||
if a == "" || b == "" {
|
||||
return false, &pathidentity.Error{Kind: pathidentity.ErrorInvalid, Stage: "input", Err: errors.New("desktop path is empty")}
|
||||
}
|
||||
return pathidentity.Same(a, b, pathidentity.Options{FollowLeaf: true})
|
||||
}
|
||||
|
||||
// projectRootKey is the map-key form of a project root: cleaned, absolute,
|
||||
// and case-folded on Windows — the same key form agent.CanonicalSessionPath
|
||||
// uses for session paths, so equivalent spellings never split lookups.
|
||||
func projectRootKey(root string) string {
|
||||
root = cleanDesktopPath(root)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
identity, err := pathidentity.Resolve(root, pathidentity.Options{FollowLeaf: true})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return identity.Key
|
||||
}
|
||||
|
||||
func restoreSessionTopicIndex(dir, sessionPath string) error {
|
||||
sessionPath = strings.TrimSpace(sessionPath)
|
||||
if sessionPath == "" {
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
// telemetry_app.go is the anonymous launch ping: one POST per app start carrying a
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/topicstate"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/topicstate"
|
||||
)
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const missingReasoningWarnStateFilename = "tool-call-reasoning-warning.json"
|
||||
|
||||
@@ -20,10 +20,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/pathidentity"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
@@ -1265,57 +1263,6 @@ func lockSessionFile(path string) (func(), error) {
|
||||
}
|
||||
}
|
||||
|
||||
// LockSessionMetaPath serializes a complete branch-meta read-modify-write
|
||||
// cycle with both goroutines in this process and other Reasonix processes.
|
||||
// Callers must hold it from the first read through the final replace.
|
||||
func LockSessionMetaPath(path string) (func(), error) {
|
||||
lockPath, err := sessionMetaLockTarget(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionMetaLockWait)
|
||||
releaseFile, err := filelock.Acquire(ctx, lockPath)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock session metadata: %w", err)
|
||||
}
|
||||
return func() {
|
||||
releaseFile()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tryLockSessionMetaPath(path string) (func(), bool, error) {
|
||||
lockPath, err := sessionMetaLockTarget(path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
releaseFile, err := filelock.TryAcquire(lockPath)
|
||||
if errors.Is(err, filelock.ErrHeld) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("try lock session metadata: %w", err)
|
||||
}
|
||||
return releaseFile, true, nil
|
||||
}
|
||||
|
||||
func sessionMetaLockTarget(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("empty session path")
|
||||
}
|
||||
canonical := canonicalSessionSavePath(path)
|
||||
if err := os.MkdirAll(filepath.Dir(canonical), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create session metadata dir: %w", err)
|
||||
}
|
||||
return sessionMetaLockPath(canonical), nil
|
||||
}
|
||||
|
||||
func sessionMetaLockPath(path string) string {
|
||||
canonical := canonicalSessionSavePath(path)
|
||||
digest := sha256.Sum256([]byte(canonical))
|
||||
return filepath.Join(filepath.Dir(canonical), "."+hex.EncodeToString(digest[:8])+".meta.lock")
|
||||
}
|
||||
|
||||
// UpdateBranchMeta is the owner-level metadata API. The callback runs while
|
||||
// the cross-process metadata lock is held, so callers cannot accidentally
|
||||
// load a stale sidecar and overwrite fields written by another runtime.
|
||||
@@ -1342,44 +1289,6 @@ func UpdateBranchMeta(path string, touchUpdated bool, update func(*BranchMeta) e
|
||||
return saveBranchMetaContextMode(context.Background(), path, m, touchUpdated, false)
|
||||
}
|
||||
|
||||
func canonicalSessionSavePath(path string) string {
|
||||
identity, err := resolveSessionPathIdentity(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return identity.PhysicalPath
|
||||
}
|
||||
|
||||
// CanonicalSessionPath is the identity key of a session path: cleaned,
|
||||
// absolute, and case-folded on Windows, matching the key form used by the
|
||||
// lease registry and the save-path locks. Any runtime bookkeeping that
|
||||
// compares or maps session paths (desktop tabs, detached runtimes) must use
|
||||
// this exact form, or the same file splits into distinct keys — e.g.
|
||||
// `C:\Users\...` vs the lease's lowercased `c:\users\...`. Empty input stays
|
||||
// empty instead of resolving to the working directory.
|
||||
func CanonicalSessionPath(path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
identity, err := resolveSessionPathIdentity(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return identity.Key
|
||||
}
|
||||
|
||||
func resolveSessionPathIdentity(path string) (pathidentity.Identity, error) {
|
||||
baseDir := ""
|
||||
if !filepath.IsAbs(strings.TrimSpace(path)) {
|
||||
var err error
|
||||
baseDir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return pathidentity.Identity{}, err
|
||||
}
|
||||
}
|
||||
return pathidentity.Resolve(path, pathidentity.Options{BaseDir: baseDir, FollowLeaf: true})
|
||||
}
|
||||
|
||||
// LoadSession reads a saved session into a fresh Session value. New sessions
|
||||
// replay the append-only event log; legacy sessions without an event log fall
|
||||
// back to the compatibility .jsonl checkpoint. A damaged log is replayed to its
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/pathidentity"
|
||||
)
|
||||
|
||||
// LockSessionMetaPath serializes a branch-meta read-modify-write cycle across
|
||||
// goroutines and processes.
|
||||
func LockSessionMetaPath(path string) (func(), error) {
|
||||
lockPath, localKey, err := sessionMetaLockTarget(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionMetaLockWait)
|
||||
releaseFile, err := filelock.AcquireModeWithKey(ctx, lockPath, localKey, filelock.ModeExclusive)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock session metadata: %w", err)
|
||||
}
|
||||
return releaseFile, nil
|
||||
}
|
||||
|
||||
func tryLockSessionMetaPath(path string) (func(), bool, error) {
|
||||
lockPath, localKey, err := sessionMetaLockTarget(path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
releaseFile, err := filelock.TryAcquireModeWithKey(lockPath, localKey, filelock.ModeExclusive)
|
||||
if errors.Is(err, filelock.ErrHeld) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("try lock session metadata: %w", err)
|
||||
}
|
||||
return releaseFile, true, nil
|
||||
}
|
||||
|
||||
func sessionMetaLockTarget(path string) (string, string, error) {
|
||||
identity, err := resolveSessionPathIdentity(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(identity.PhysicalPath), 0o755); err != nil {
|
||||
return "", "", fmt.Errorf("create session metadata dir: %w", err)
|
||||
}
|
||||
return sessionMetaLockPath(identity.PhysicalPath), identity.Key + "\x00session-meta", nil
|
||||
}
|
||||
|
||||
func sessionMetaLockPath(path string) string {
|
||||
canonical := canonicalSessionSavePath(path)
|
||||
digest := sha256.Sum256([]byte(canonical))
|
||||
return filepath.Join(filepath.Dir(canonical), "."+hex.EncodeToString(digest[:8])+".meta.lock")
|
||||
}
|
||||
|
||||
func canonicalSessionSavePath(path string) string {
|
||||
identity, err := resolveSessionPathIdentity(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return identity.PhysicalPath
|
||||
}
|
||||
|
||||
// CanonicalSessionPath returns the comparison key shared by session owners,
|
||||
// lease registries, and save queues. Empty input never becomes the current dir.
|
||||
func CanonicalSessionPath(path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
identity, err := resolveSessionPathIdentity(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return identity.Key
|
||||
}
|
||||
|
||||
func resolveSessionPathIdentity(path string) (pathidentity.Identity, error) {
|
||||
baseDir := ""
|
||||
if !filepath.IsAbs(strings.TrimSpace(path)) {
|
||||
var err error
|
||||
baseDir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return pathidentity.Identity{}, err
|
||||
}
|
||||
}
|
||||
return pathidentity.Resolve(path, pathidentity.Options{BaseDir: baseDir, FollowLeaf: true})
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/recovery"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/mcplaunch"
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
// userEditMu serializes in-process read-modify-write cycles. The public lock
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package filelock
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -18,15 +19,15 @@ func TestCanonicalLockPathKeepsLegacyDarwinCase(t *testing.T) {
|
||||
t.Fatalf("canonical lock path = %q, want exact legacy case", got)
|
||||
}
|
||||
alias := filepath.Join(filepath.Dir(got), "mixedcase.lock")
|
||||
gotKey, err := localRegistryKey(got)
|
||||
release, err := TryAcquireModeWithKey(got, strings.ToLower(got), ModeExclusive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aliasKey, err := localRegistryKey(alias)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotKey != aliasKey {
|
||||
t.Fatalf("local registry did not fold case aliases: %q / %q", got, alias)
|
||||
defer release()
|
||||
if aliasRelease, err := TryAcquireModeWithKey(alias, strings.ToLower(alias), ModeExclusive); !errors.Is(err, ErrHeld) {
|
||||
if aliasRelease != nil {
|
||||
aliasRelease()
|
||||
}
|
||||
t.Fatalf("local registry did not share explicit alias identity: %q / %q: %v", got, alias, err)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/pathidentity"
|
||||
)
|
||||
|
||||
const retryInterval = 20 * time.Millisecond
|
||||
@@ -50,6 +48,16 @@ func AcquireMode(ctx context.Context, path string, mode Mode) (func(), error) {
|
||||
return acquire(ctx, path, 0, mode)
|
||||
}
|
||||
|
||||
// AcquireModeWithKey uses localKey for the process-local queue while opening
|
||||
// path for the cross-process lock. Callers with filesystem identity knowledge
|
||||
// use this to make aliases share a queue without using a comparison key for IO.
|
||||
func AcquireModeWithKey(ctx context.Context, path, localKey string, mode Mode) (func(), error) {
|
||||
if localKey == "" {
|
||||
return nil, errors.New("file lock local key is empty")
|
||||
}
|
||||
return acquireWithKey(ctx, path, localKey, 0, mode)
|
||||
}
|
||||
|
||||
// AcquireWithExternalTimeout obtains an exclusive lock while keeping the
|
||||
// in-process queue and cross-process file-lock budgets separate. ctx bounds
|
||||
// only the wait for another goroutine in this process; externalTimeout starts
|
||||
@@ -61,7 +69,23 @@ func AcquireWithExternalTimeout(ctx context.Context, path string, externalTimeou
|
||||
return acquire(ctx, path, externalTimeout, ModeExclusive)
|
||||
}
|
||||
|
||||
// AcquireWithExternalTimeoutAndKey is AcquireWithExternalTimeout with an
|
||||
// explicit process-local identity key.
|
||||
func AcquireWithExternalTimeoutAndKey(ctx context.Context, path, localKey string, externalTimeout time.Duration) (func(), error) {
|
||||
if externalTimeout <= 0 {
|
||||
return nil, errors.New("external file lock timeout must be positive")
|
||||
}
|
||||
if localKey == "" {
|
||||
return nil, errors.New("file lock local key is empty")
|
||||
}
|
||||
return acquireWithKey(ctx, path, localKey, externalTimeout, ModeExclusive)
|
||||
}
|
||||
|
||||
func acquire(ctx context.Context, path string, externalTimeout time.Duration, mode Mode) (func(), error) {
|
||||
return acquireWithKey(ctx, path, "", externalTimeout, mode)
|
||||
}
|
||||
|
||||
func acquireWithKey(ctx context.Context, path, localKey string, externalTimeout time.Duration, mode Mode) (func(), error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -69,9 +93,9 @@ func acquire(ctx context.Context, path string, externalTimeout time.Duration, mo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := localRegistryKey(lockPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
key := localKey
|
||||
if key == "" {
|
||||
key = lockPath
|
||||
}
|
||||
releaseLocal, err := acquireLocal(ctx, key, mode)
|
||||
if err != nil {
|
||||
@@ -123,13 +147,25 @@ func TryAcquire(path string) (func(), error) {
|
||||
|
||||
// TryAcquireMode attempts a non-blocking lock in exclusive or shared mode.
|
||||
func TryAcquireMode(path string, mode Mode) (func(), error) {
|
||||
return tryAcquireModeWithKey(path, "", mode)
|
||||
}
|
||||
|
||||
// TryAcquireModeWithKey is the non-blocking form of AcquireModeWithKey.
|
||||
func TryAcquireModeWithKey(path, localKey string, mode Mode) (func(), error) {
|
||||
if localKey == "" {
|
||||
return nil, errors.New("file lock local key is empty")
|
||||
}
|
||||
return tryAcquireModeWithKey(path, localKey, mode)
|
||||
}
|
||||
|
||||
func tryAcquireModeWithKey(path, localKey string, mode Mode) (func(), error) {
|
||||
lockPath, err := canonicalLockPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := localRegistryKey(lockPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
key := localKey
|
||||
if key == "" {
|
||||
key = lockPath
|
||||
}
|
||||
releaseLocal, ok := tryAcquireLocal(key, mode)
|
||||
if !ok {
|
||||
@@ -295,11 +331,3 @@ func canonicalLockPath(path string) (string, error) {
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
func localRegistryKey(path string) (string, error) {
|
||||
identity, err := pathidentity.Resolve(path, pathidentity.Options{FollowLeaf: false})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve file lock identity: %w", err)
|
||||
}
|
||||
return identity.Key, nil
|
||||
}
|
||||
@@ -23,6 +23,21 @@ func TestTryAcquireModeSharedIsNonBlocking(t *testing.T) {
|
||||
second()
|
||||
}
|
||||
|
||||
func TestExplicitLocalKeySerializesDifferentAccessPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first, err := TryAcquireModeWithKey(filepath.Join(dir, "first.lock"), "shared-identity", ModeExclusive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first()
|
||||
if release, err := TryAcquireModeWithKey(filepath.Join(dir, "second.lock"), "shared-identity", ModeExclusive); !errors.Is(err, ErrHeld) {
|
||||
if release != nil {
|
||||
release()
|
||||
}
|
||||
t.Fatalf("second acquire error = %v, want ErrHeld", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingWriterBlocksNewLocalReaders(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.lock")
|
||||
reader, err := AcquireMode(context.Background(), path, ModeShared)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package identitylock combines filesystem path identity with filelock's
|
||||
// process-local queue and cross-process advisory lock.
|
||||
package identitylock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/pathidentity"
|
||||
)
|
||||
|
||||
type Mode = filelock.Mode
|
||||
|
||||
const (
|
||||
ModeExclusive = filelock.ModeExclusive
|
||||
ModeShared = filelock.ModeShared
|
||||
)
|
||||
|
||||
var ErrHeld = filelock.ErrHeld
|
||||
|
||||
func Acquire(ctx context.Context, path string) (func(), error) {
|
||||
return AcquireMode(ctx, path, ModeExclusive)
|
||||
}
|
||||
|
||||
func AcquireMode(ctx context.Context, path string, mode Mode) (func(), error) {
|
||||
accessPath, key, err := resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filelock.AcquireModeWithKey(ctx, accessPath, key, mode)
|
||||
}
|
||||
|
||||
func AcquireWithExternalTimeout(ctx context.Context, path string, timeout time.Duration) (func(), error) {
|
||||
accessPath, key, err := resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filelock.AcquireWithExternalTimeoutAndKey(ctx, accessPath, key, timeout)
|
||||
}
|
||||
|
||||
func TryAcquire(path string) (func(), error) {
|
||||
return TryAcquireMode(path, ModeExclusive)
|
||||
}
|
||||
|
||||
func TryAcquireMode(path string, mode Mode) (func(), error) {
|
||||
accessPath, key, err := resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filelock.TryAcquireModeWithKey(accessPath, key, mode)
|
||||
}
|
||||
|
||||
func resolve(path string) (string, string, error) {
|
||||
baseDir := ""
|
||||
if !filepath.IsAbs(strings.TrimSpace(path)) {
|
||||
var err error
|
||||
baseDir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve file lock identity: %w", err)
|
||||
}
|
||||
}
|
||||
identity, err := pathidentity.Resolve(path, pathidentity.Options{BaseDir: baseDir, FollowLeaf: true})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve file lock identity: %w", err)
|
||||
}
|
||||
return identity.AccessPath, identity.Key, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package identitylock
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAliasPathsShareLocalIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
realDir := filepath.Join(dir, "real")
|
||||
if err := os.Mkdir(realDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aliasDir := filepath.Join(dir, "alias")
|
||||
if err := os.Symlink(realDir, aliasDir); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
release, err := TryAcquire(filepath.Join(realDir, "state.lock"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer release()
|
||||
if aliasRelease, err := TryAcquire(filepath.Join(aliasDir, "state.lock")); !errors.Is(err, ErrHeld) {
|
||||
if aliasRelease != nil {
|
||||
aliasRelease()
|
||||
}
|
||||
t.Fatalf("alias acquire error = %v, want ErrHeld", err)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const activationLockName = ".reasonix-activate.lock"
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
package pathidentity
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
@@ -58,15 +58,29 @@ func directoryCaseInsensitive(path string) (insensitive, exists bool, err error)
|
||||
return false, false, err
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var info [4]byte
|
||||
err = windows.GetFileInformationByHandleEx(handle, windows.FileCaseSensitiveInfo, &info[0], uint32(len(info)))
|
||||
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) || errors.Is(err, windows.ERROR_NOT_SUPPORTED) {
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
var flags uint32
|
||||
err = windows.NtQueryInformationFile(
|
||||
handle,
|
||||
&iosb,
|
||||
(*byte)(unsafe.Pointer(&flags)),
|
||||
uint32(unsafe.Sizeof(flags)),
|
||||
windows.FileCaseSensitiveInformation,
|
||||
)
|
||||
if caseSensitivityQueryUnsupported(err) {
|
||||
return true, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return binary.LittleEndian.Uint32(info[:])&windows.FILE_CS_FLAG_CASE_SENSITIVE_DIR == 0, true, nil
|
||||
return flags&windows.FILE_CS_FLAG_CASE_SENSITIVE_DIR == 0, true, nil
|
||||
}
|
||||
|
||||
func caseSensitivityQueryUnsupported(err error) bool {
|
||||
status, ok := err.(windows.NTStatus)
|
||||
return ok && (status == windows.STATUS_INVALID_INFO_CLASS ||
|
||||
status == windows.STATUS_INVALID_PARAMETER ||
|
||||
status == windows.STATUS_NOT_SUPPORTED)
|
||||
}
|
||||
|
||||
func stripExtendedPrefix(path string) string {
|
||||
|
||||
@@ -28,3 +28,14 @@ func TestWindowsIdentityKeyFoldsPerDirectory(t *testing.T) {
|
||||
t.Fatalf("segment-aware identity = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryCaseInsensitiveQueriesNativeDirectory(t *testing.T) {
|
||||
insensitive, exists, err := directoryCaseInsensitive(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("query directory case sensitivity: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("temporary directory should exist")
|
||||
}
|
||||
_ = insensitive
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
// oauthRefreshGates prevent duplicate refresh requests from transports in the
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/sqliteuri"
|
||||
|
||||
moderncsqlite "modernc.org/sqlite"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -9,13 +8,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/pathidentity"
|
||||
)
|
||||
|
||||
@@ -64,10 +61,8 @@ func restoreRepairNodeIfAbsent(backup, target string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeRepairNodeIfMatching first displaces a transaction-owned backup to a
|
||||
// unique sibling, then verifies the moved node against its original target
|
||||
// identity. A path replacement between verification and cleanup is restored or
|
||||
// retained, never unlinked as if it were transaction-owned.
|
||||
// removeRepairNodeIfMatching displaces and verifies a transaction backup.
|
||||
// Replaced paths are restored or retained, never unlinked as transaction-owned.
|
||||
func removeRepairNodeIfMatching(path, identityPath, expectedStateID string) error {
|
||||
expectedStateID = strings.TrimSpace(expectedStateID)
|
||||
if expectedStateID == "" {
|
||||
@@ -254,132 +249,33 @@ func lockRepairMutationsTimeoutMode(timeout time.Duration, revalidateTargets boo
|
||||
return nil, fmt.Errorf("lock repair mutations: create lock directory: %w", err)
|
||||
}
|
||||
|
||||
type targetIdentity struct {
|
||||
path string
|
||||
key string
|
||||
info os.FileInfo
|
||||
exists bool
|
||||
}
|
||||
targets := make([]targetIdentity, 0, len(paths))
|
||||
primaryUnique := map[string]struct{}{}
|
||||
lockKeySet := map[string]struct{}{}
|
||||
for _, path := range paths {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
identity, err := pathidentity.Resolve(path, pathidentity.Options{FollowLeaf: false})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock repair mutations: resolve target: %w", err)
|
||||
}
|
||||
if _, ok := primaryUnique[identity.Key]; ok {
|
||||
continue
|
||||
}
|
||||
info, statErr := os.Lstat(identity.AccessPath)
|
||||
exists := statErr == nil
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
return nil, fmt.Errorf("lock repair mutations: inspect target: %w", statErr)
|
||||
}
|
||||
primaryUnique[identity.Key] = struct{}{}
|
||||
targets = append(targets, targetIdentity{path: identity.AccessPath, key: identity.Key, info: info, exists: exists})
|
||||
lockKeySet[identity.Key] = struct{}{}
|
||||
if legacy := legacyCanonicalRepairPath(path); legacy != "" {
|
||||
lockKeySet[legacy] = struct{}{}
|
||||
}
|
||||
targets, primaryKeys, lockKeys, err := repairMutationTargets(paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return func() {}, nil
|
||||
}
|
||||
primaryKeys := make([]string, 0, len(primaryUnique))
|
||||
for key := range primaryUnique {
|
||||
primaryKeys = append(primaryKeys, key)
|
||||
}
|
||||
sort.Strings(primaryKeys)
|
||||
repairMutationBeforeLock(append([]string(nil), primaryKeys...))
|
||||
lockPaths := make([]string, 0, len(lockKeySet))
|
||||
for key := range lockKeySet {
|
||||
digest := sha256.Sum256([]byte(key))
|
||||
lockPaths = append(lockPaths, filepath.Join(lockDir, fmt.Sprintf("%x.lock", digest)))
|
||||
domains, err := repairMutationLockDomains(lockDir, lockKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(lockPaths)
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = repairMutationLockTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
var releases []func()
|
||||
for {
|
||||
releases = releases[:0]
|
||||
contended := false
|
||||
for _, lockPath := range lockPaths {
|
||||
release, acquireErr := filelock.TryAcquire(lockPath)
|
||||
if acquireErr == nil {
|
||||
releases = append(releases, release)
|
||||
continue
|
||||
}
|
||||
for _, held := range slices.Backward(releases) {
|
||||
held()
|
||||
}
|
||||
releases = releases[:0]
|
||||
if !errors.Is(acquireErr, filelock.ErrHeld) {
|
||||
return nil, fmt.Errorf("lock repair mutations: %w", acquireErr)
|
||||
}
|
||||
contended = true
|
||||
break
|
||||
}
|
||||
if !contended {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(20 * time.Millisecond)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return nil, fmt.Errorf("lock repair mutations: %w", ctx.Err())
|
||||
}
|
||||
releases, err := acquireRepairMutationLocks(timeout, domains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if revalidateTargets {
|
||||
for _, target := range targets {
|
||||
identity, resolveErr := pathidentity.Resolve(target.path, pathidentity.Options{FollowLeaf: false})
|
||||
currentInfo, statErr := os.Lstat(target.path)
|
||||
currentExists := statErr == nil
|
||||
if statErr != nil && !os.IsNotExist(statErr) && resolveErr == nil {
|
||||
resolveErr = statErr
|
||||
}
|
||||
identityChanged := resolveErr == nil && (identity.Key != target.key || repairEntryRedirected(target.info, target.exists, currentInfo, currentExists))
|
||||
if resolveErr != nil || identityChanged {
|
||||
for _, held := range slices.Backward(releases) {
|
||||
held()
|
||||
}
|
||||
if resolveErr != nil {
|
||||
return nil, fmt.Errorf("lock repair mutations: revalidate target: %w", resolveErr)
|
||||
}
|
||||
return nil, fmt.Errorf("lock repair mutations: target identity changed while waiting")
|
||||
}
|
||||
if err := revalidateRepairMutationTargets(targets); err != nil {
|
||||
releaseRepairMutationLocks(releases)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
for _, v := range slices.Backward(releases) {
|
||||
v()
|
||||
}
|
||||
releaseRepairMutationLocks(releases)
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
func repairEntryRedirected(before os.FileInfo, beforeExists bool, after os.FileInfo, afterExists bool) bool {
|
||||
// Regular files are commonly published with rename(2) while holding this
|
||||
// lock. A waiter still owns the same parent directory and leaf name after
|
||||
// that replacement; the caller's content/hash precondition detects stale
|
||||
// state. Directory entries, links, and special nodes retain native identity
|
||||
// because replacing one can redirect the authorized operation elsewhere.
|
||||
if (!beforeExists || before.Mode().IsRegular()) && (!afterExists || after.Mode().IsRegular()) {
|
||||
return false
|
||||
}
|
||||
return beforeExists != afterExists || !os.SameFile(before, after)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/pathidentity"
|
||||
)
|
||||
|
||||
type repairMutationTarget struct {
|
||||
path string
|
||||
key string
|
||||
info os.FileInfo
|
||||
exists bool
|
||||
}
|
||||
|
||||
type repairMutationLockDomain struct {
|
||||
path string
|
||||
key string
|
||||
}
|
||||
|
||||
func repairMutationTargets(paths []string) ([]repairMutationTarget, []string, []string, error) {
|
||||
targets := make([]repairMutationTarget, 0, len(paths))
|
||||
primary := map[string]struct{}{}
|
||||
locks := map[string]struct{}{}
|
||||
for _, path := range paths {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
identity, err := pathidentity.Resolve(path, pathidentity.Options{FollowLeaf: false})
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("lock repair mutations: resolve target: %w", err)
|
||||
}
|
||||
if _, exists := primary[identity.Key]; exists {
|
||||
continue
|
||||
}
|
||||
info, statErr := os.Lstat(identity.AccessPath)
|
||||
exists := statErr == nil
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
return nil, nil, nil, fmt.Errorf("lock repair mutations: inspect target: %w", statErr)
|
||||
}
|
||||
primary[identity.Key] = struct{}{}
|
||||
targets = append(targets, repairMutationTarget{identity.AccessPath, identity.Key, info, exists})
|
||||
locks[identity.Key] = struct{}{}
|
||||
if legacy := legacyCanonicalRepairPath(path); legacy != "" {
|
||||
locks[legacy] = struct{}{}
|
||||
}
|
||||
}
|
||||
return targets, sortedRepairKeys(primary), sortedRepairKeys(locks), nil
|
||||
}
|
||||
|
||||
func sortedRepairKeys(set map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func repairMutationLockDomains(lockDir string, keys []string) ([]repairMutationLockDomain, error) {
|
||||
domains := make([]repairMutationLockDomain, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
digest := sha256.Sum256([]byte(key))
|
||||
path := filepath.Join(lockDir, fmt.Sprintf("%x.lock", digest))
|
||||
identity, err := pathidentity.Resolve(path, pathidentity.Options{FollowLeaf: false})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock repair mutations: resolve lock identity: %w", err)
|
||||
}
|
||||
domains = append(domains, repairMutationLockDomain{path: path, key: identity.Key})
|
||||
}
|
||||
sort.Slice(domains, func(i, j int) bool { return domains[i].key < domains[j].key })
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
func acquireRepairMutationLocks(timeout time.Duration, domains []repairMutationLockDomain) ([]func(), error) {
|
||||
if timeout <= 0 {
|
||||
timeout = repairMutationLockTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
for {
|
||||
releases := make([]func(), 0, len(domains))
|
||||
for _, domain := range domains {
|
||||
release, err := filelock.TryAcquireModeWithKey(domain.path, domain.key, filelock.ModeExclusive)
|
||||
if err == nil {
|
||||
releases = append(releases, release)
|
||||
continue
|
||||
}
|
||||
releaseRepairMutationLocks(releases)
|
||||
if !errors.Is(err, filelock.ErrHeld) {
|
||||
return nil, fmt.Errorf("lock repair mutations: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if len(releases) == len(domains) {
|
||||
return releases, nil
|
||||
}
|
||||
timer := time.NewTimer(20 * time.Millisecond)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("lock repair mutations: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func revalidateRepairMutationTargets(targets []repairMutationTarget) error {
|
||||
for _, target := range targets {
|
||||
identity, resolveErr := pathidentity.Resolve(target.path, pathidentity.Options{FollowLeaf: false})
|
||||
currentInfo, statErr := os.Lstat(target.path)
|
||||
currentExists := statErr == nil
|
||||
if statErr != nil && !os.IsNotExist(statErr) && resolveErr == nil {
|
||||
resolveErr = statErr
|
||||
}
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("lock repair mutations: revalidate target: %w", resolveErr)
|
||||
}
|
||||
if identity.Key != target.key || repairEntryRedirected(target.info, target.exists, currentInfo, currentExists) {
|
||||
return errors.New("lock repair mutations: target identity changed while waiting")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseRepairMutationLocks(releases []func()) {
|
||||
for _, release := range slices.Backward(releases) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// Regular files may be atomically replaced under the lock; content checks
|
||||
// detect stale state. Other node types retain native identity.
|
||||
func repairEntryRedirected(before os.FileInfo, beforeExists bool, after os.FileInfo, afterExists bool) bool {
|
||||
if (!beforeExists || before.Mode().IsRegular()) && (!afterExists || after.Mode().IsRegular()) {
|
||||
return false
|
||||
}
|
||||
return beforeExists != afterExists || !os.SameFile(before, after)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
// StartupState is the legacy startup-state.json shape written by v1.18-v1.19.
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const updateTransactionVersion = 1
|
||||
|
||||
@@ -17,9 +17,7 @@ import (
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/eventwire"
|
||||
"reasonix/internal/pathidentity"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// Session ownership handoff: the single-writer protocol behind local takeover.
|
||||
@@ -235,44 +233,6 @@ func (s *Server) snapshotForeground(cur control.SessionAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSessionPath validates a client-supplied session path against the
|
||||
// foreground session dir the same way POST /resume does: absolute, a real
|
||||
// transcript file, inside the session dir, and not pending cleanup. The
|
||||
// returned path is symlink-resolved. Containment is checked with identity keys
|
||||
// so a comparison key from a case-insensitive volume is never mistaken for a
|
||||
// different on-disk directory spelling.
|
||||
func (s *Server) resolveSessionPath(raw string) (string, error) {
|
||||
dir := s.ctl().SessionDir()
|
||||
if dir == "" {
|
||||
return "", errors.New("sessions disabled")
|
||||
}
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid session dir")
|
||||
}
|
||||
dirIdentity, err := pathidentity.Resolve(absDir, pathidentity.Options{FollowLeaf: true})
|
||||
if err != nil {
|
||||
return "", errors.New("invalid session dir")
|
||||
}
|
||||
absPath, err := filepath.Abs(strings.TrimSpace(raw))
|
||||
if err != nil || !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
||||
return "", errors.New("invalid session path")
|
||||
}
|
||||
pathIdentity, err := pathidentity.Resolve(absPath, pathidentity.Options{FollowLeaf: true})
|
||||
if err != nil {
|
||||
return "", errors.New("invalid session path")
|
||||
}
|
||||
rel, err := filepath.Rel(dirIdentity.Key, pathIdentity.Key)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", errors.New("path outside session dir")
|
||||
}
|
||||
realPath := pathIdentity.PhysicalPath
|
||||
if agent.IsCleanupPending(realPath) {
|
||||
return "", errors.New("session is pending cleanup")
|
||||
}
|
||||
return realPath, nil
|
||||
}
|
||||
|
||||
type ownershipView struct {
|
||||
SessionPath string `json:"sessionPath"`
|
||||
Holder string `json:"holder"` // serve | external | other | free
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/pathidentity"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// resolveSessionPath validates a client-supplied transcript against the
|
||||
// configured session directory and returns its symlink-resolved access path.
|
||||
func (s *Server) resolveSessionPath(raw string) (string, error) {
|
||||
dir := s.ctl().SessionDir()
|
||||
if dir == "" {
|
||||
return "", errors.New("sessions disabled")
|
||||
}
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid session dir")
|
||||
}
|
||||
dirIdentity, err := pathidentity.Resolve(absDir, pathidentity.Options{FollowLeaf: true})
|
||||
if err != nil {
|
||||
return "", errors.New("invalid session dir")
|
||||
}
|
||||
absPath, err := filepath.Abs(strings.TrimSpace(raw))
|
||||
if err != nil || !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
||||
return "", errors.New("invalid session path")
|
||||
}
|
||||
pathIdentity, err := pathidentity.Resolve(absPath, pathidentity.Options{FollowLeaf: true})
|
||||
if err != nil {
|
||||
return "", errors.New("invalid session path")
|
||||
}
|
||||
rel, err := filepath.Rel(dirIdentity.Key, pathIdentity.Key)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", errors.New("path outside session dir")
|
||||
}
|
||||
realPath := pathIdentity.PhysicalPath
|
||||
if agent.IsCleanupPending(realPath) {
|
||||
return "", errors.New("session is pending cleanup")
|
||||
}
|
||||
return realPath, nil
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package session
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
func directoryOwnershipPath(dir string) string {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
// PurgeWithTombstone keeps directory ownership across writer-lock release and
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/sessioncontent"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
type frozenPreview struct {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"runtime"
|
||||
"syscall"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package sessiontemp
|
||||
|
||||
import "reasonix/internal/filelock"
|
||||
import filelock "reasonix/internal/identitylock"
|
||||
|
||||
func tryLockForTest(path string) (func(), error) {
|
||||
return filelock.Acquire(nilContext(), path)
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
filelock "reasonix/internal/identitylock"
|
||||
"reasonix/internal/usagecatalog"
|
||||
)
|
||||
|
||||
|
||||
@@ -725,8 +725,20 @@ func normalizeIdentityPath(path string) string {
|
||||
return identity.Key
|
||||
}
|
||||
|
||||
func lockIdentityKey(path string) (string, error) {
|
||||
identity, err := pathidentity.Resolve(path, pathidentity.Options{FollowLeaf: false})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return identity.Key, nil
|
||||
}
|
||||
|
||||
func (o *Owner) acquireMode(ctx context.Context, path string, mode filelock.Mode, notified *bool) (func(), error) {
|
||||
release, err := filelock.TryAcquireMode(path, mode)
|
||||
localKey, err := lockIdentityKey(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve workspace lock identity: %w", err)
|
||||
}
|
||||
release, err := filelock.TryAcquireModeWithKey(path, localKey, mode)
|
||||
if err == nil {
|
||||
return release, nil
|
||||
}
|
||||
@@ -739,7 +751,7 @@ func (o *Owner) acquireMode(ctx context.Context, path string, mode filelock.Mode
|
||||
o.onWait()
|
||||
}
|
||||
}
|
||||
release, err = filelock.AcquireMode(ctx, path, mode)
|
||||
release, err = filelock.AcquireModeWithKey(ctx, path, localKey, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire workspace write lease: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
type rootLockDomain struct {
|
||||
path string
|
||||
key string
|
||||
mode filelock.Mode
|
||||
}
|
||||
|
||||
@@ -98,7 +99,7 @@ func acquireRootDomains(ctx context.Context, owner *Owner, domains []rootLockDom
|
||||
var blocked *rootLockDomain
|
||||
for i := range domains {
|
||||
domain := &domains[i]
|
||||
release, err := filelock.TryAcquireMode(domain.path, domain.mode)
|
||||
release, err := filelock.TryAcquireModeWithKey(domain.path, domain.key, domain.mode)
|
||||
if err == nil {
|
||||
releases = append(releases, release)
|
||||
continue
|
||||
@@ -151,7 +152,11 @@ func rootLockDomains(lockDir string, roots []string) (*Owner, []rootLockDomain,
|
||||
if exclusive[normalizeIdentityPath(root)] {
|
||||
mode = filelock.ModeExclusive
|
||||
}
|
||||
domains = append(domains, rootLockDomain{workspaceLockPath(lockDir, root), mode})
|
||||
domain, err := makeRootLockDomain(workspaceLockPath(lockDir, root), mode)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
paths := make([]string, 0, len(trees))
|
||||
for path := range trees {
|
||||
@@ -159,7 +164,19 @@ func rootLockDomains(lockDir string, roots []string) (*Owner, []rootLockDomain,
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
domains = append(domains, rootLockDomain{path, filelock.ModeExclusive})
|
||||
domain, err := makeRootLockDomain(path, filelock.ModeExclusive)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
return coordinator, domains, nil
|
||||
}
|
||||
|
||||
func makeRootLockDomain(path string, mode filelock.Mode) (rootLockDomain, error) {
|
||||
key, err := lockIdentityKey(path)
|
||||
if err != nil {
|
||||
return rootLockDomain{}, err
|
||||
}
|
||||
return rootLockDomain{path: path, key: key, mode: mode}, nil
|
||||
}
|
||||
Reference in new issue
Block a user