Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/localnet/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const (
// catalogue lookup, upstream fallback if AllowUncurated.
StepResolveVersion Step = "resolve_version"
// StepAcquireLock: 2 · "Acquire instance lock" — flock on
// ~/.canton-devkit/localnet/<name>/.lock.
// ~/.canton-devkit/localnet/.locks/<name>.lock.
StepAcquireLock Step = "acquire_lock"
// StepPreflight: 3 · "Run preflight checks" — docker daemon /
// compose v2 / disk / memory thresholds.
Expand Down
9 changes: 6 additions & 3 deletions internal/registry/lock_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ import (
// blocking concurrent `localnet up`/`down` against the same instance from
// the same host. The returned release function must be called via defer.
// The Windows counterpart (LockFileEx) lives in lock_windows.go.
//
// The lock file lives at .locks/<name>.lock under the registry root
// (outside the instance data dir) so Delete can RemoveAll the data dir
// while this lock is still held.
func Lock(name string) (release func(), err error) {
if err := ValidateName(name); err != nil {
return nil, err
}
dir := DataDirFor(name)
if err := os.MkdirAll(dir, 0o700); err != nil {
path := LockPathFor(name)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("mkdir for lock: %w", err)
}
path := filepath.Join(dir, "state.json.lock")

f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
Expand Down
9 changes: 6 additions & 3 deletions internal/registry/lock_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ import (
// LOCK_EX|LOCK_NB behaviour on Unix. The OS releases the lock byte-range
// automatically when the handle is closed or the process exits, so there
// is no stale-lock file to recover after a crash.
//
// The lock file lives at .locks/<name>.lock under the registry root
// (outside the instance data dir) so Delete can RemoveAll the data dir
// while this lock is still held — Windows cannot unlink an open file.
func Lock(name string) (release func(), err error) {
if err := ValidateName(name); err != nil {
return nil, err
}
dir := DataDirFor(name)
if err := os.MkdirAll(dir, 0o700); err != nil {
path := LockPathFor(name)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("mkdir for lock: %w", err)
}
path := filepath.Join(dir, "state.json.lock")

h, err := openLockHandle(path)
if err != nil {
Expand Down
20 changes: 18 additions & 2 deletions internal/registry/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
// On-disk layout under ~/.canton-devkit/localnet/:
//
// index.json — directory of every instance (for `list`)
// .locks/<name>.lock — flock/LockFileEx advisory lock for concurrent ops
// <name>/state.json — full per-instance metadata
// <name>/state.json.lock — flock advisory lock for concurrent ops
// <name>/overlay.env — generated env-file overlay (written by up)
// <name>/containers.yaml — generated container-rename overlay
//
// The lock lives outside the instance data dir so `Delete` can
// `RemoveAll` the data dir while the lock is still held — required on
// Windows, which cannot unlink an open file.
//
// All writes are atomic (tmp + rename), state.json is mode 0600 (it
// holds captured JWTs), and concurrent up/down on the same instance is
// rejected by the lock.
Expand Down Expand Up @@ -181,7 +185,7 @@ var validInstanceName = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])

// ValidateName rejects instance names that could escape the registry
// root or break tooling. Called from every public entry point that
// accepts a name (PathFor, DataDirFor, Read, Write, Delete, Lock).
// accepts a name (PathFor, DataDirFor, LockPathFor, Read, Write, Delete, Lock).
// The check is deliberately conservative — it's far easier to widen
// later than to narrow after users depend on quirky names.
//
Expand Down Expand Up @@ -248,6 +252,18 @@ func DataDirFor(name string) string {
return filepath.Join(Root(), name)
}

// LockPathFor returns the per-instance advisory lock path
// (.locks/<name>.lock under the registry root). Kept outside the data
// dir so Delete can RemoveAll the instance directory while Lock still
// holds the file open (Windows cannot unlink an open handle).
// Same panic-on-invalid semantics as PathFor.
func LockPathFor(name string) string {
if err := ValidateName(name); err != nil {
panic(fmt.Sprintf("registry.LockPathFor called with invalid name: %v", err))
}
return filepath.Join(Root(), ".locks", name+".lock")
}

// NewState builds a fresh State with defaults applied. Callers fill in the
// rest before the first Write.
func NewState(name, spliceVersion string) *State {
Expand Down
35 changes: 35 additions & 0 deletions internal/registry/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,41 @@ func TestLockExcludesConcurrentOps(t *testing.T) {
rel2()
}

// TestDeleteWhileLockedSucceeds is the regression for Windows remove:
// Delete must RemoveAll the instance data dir while Lock still holds
// the open handle. That only works because the lock lives outside the
// data dir (.locks/<name>.lock). Runs on all platforms — do not skip
// on Windows.
func TestDeleteWhileLockedSucceeds(t *testing.T) {
useTmpRoot(t)

if err := Write(NewState("alice", "0.6.4")); err != nil {
t.Fatal(err)
}

release, err := Lock("alice")
if err != nil {
t.Fatalf("lock: %v", err)
}

if err := Delete("alice"); err != nil {
release()
t.Fatalf("Delete while holding lock: %v", err)
}
if _, err := Read("alice"); err != ErrNotFound {
release()
t.Fatalf("after Delete: want ErrNotFound, got %v", err)
}

release()

release2, err := Lock("alice")
if err != nil {
t.Fatalf("re-lock after Delete+release: %v", err)
}
release2()
}

func TestConcurrentWritesDifferentInstancesAllSucceed(t *testing.T) {
useTmpRoot(t)

Expand Down
Loading