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
31 changes: 20 additions & 11 deletions internal/kernel/put.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ type PutResult struct {
// refresh and its push, so the push is genuinely rejected.
var beforePushHook func()

// releaseHook, when non-nil, is joined into cortexLock.release then
// disarmed. Production leaves it nil; tests inject unlock failure
// after the critical section has already committed its result.
var releaseHook func() error

// Put runs the pinned pipeline: lock → refresh → pre-flight → CAS →
// validate → no-op short-circuit → stamp → atomic write → VCS tail,
// all inside one per-cortex critical section.
Expand All @@ -59,7 +64,13 @@ func Put(ctx context.Context, cs []Cortex, in PutInput) (res *PutResult, conf *C
return nil, bound
}
defer func() {
conf = attachUnlock(conf, lock.release(), op, rel)
rerr := lock.release()
conf = attachUnlock(conf, rerr, op, rel)
if rerr != nil && conf == nil && res != nil {
res.Warnings = append(res.Warnings, fm.Finding{
Level: "warning", Rule: "unlock_failed", Message: rerr.Error(),
})
}
}()

res = &PutResult{Operation: op, Cortex: c.Name, Path: rel}
Expand Down Expand Up @@ -470,17 +481,18 @@ func acquireLock(name string) (*cortexLock, error) {
type cortexLock struct{ f *os.File }

func (l *cortexLock) release() error {
return errors.Join(syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN), l.f.Close())
err := errors.Join(syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN), l.f.Close())
if releaseHook != nil {
err = errors.Join(err, releaseHook())
releaseHook = nil
}
return err
}

func attachUnlock(conf *Conflict, rerr error, operation, path string) *Conflict {
if rerr == nil {
if rerr == nil || conf == nil {
return conf
}
if conf == nil {
return conflict("lock_failed", operation, path, "fix lock-file access and retry",
map[string]any{"detail": rerr.Error()})
}
if conf.Detail == nil {
conf.Detail = map[string]any{}
}
Expand All @@ -489,11 +501,8 @@ func attachUnlock(conf *Conflict, rerr error, operation, path string) *Conflict
}

func attachUnlockErr(err, rerr error, operation, path string) error {
if rerr == nil {
return err
}
if err == nil {
return attachUnlock(nil, rerr, operation, path)
return nil
}
if conf, ok := err.(*Conflict); ok {
return attachUnlock(conf, rerr, operation, path)
Expand Down
7 changes: 3 additions & 4 deletions internal/kernel/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,9 @@ func withReadSnapshot(c *Cortex, operation, path string, fn func(readSnapshot) *
return fail(errors.New("publisher repository has no HEAD commit"))
}
snapshot := readSnapshot{repo: root, sha: head}
if rerr := lock.release(); rerr != nil {
return attachUnlock(nil, rerr, operation, path)
}
return fn(snapshot)
rerr := lock.release()
conf := fn(snapshot)
return attachUnlock(conf, rerr, operation, path)
}

func snapshotUnavailable(operation, path string, err error) *Conflict {
Expand Down
66 changes: 66 additions & 0 deletions internal/kernel/unlock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package kernel

import (
"errors"
"strings"
"testing"
)

func TestAttachUnlockPreservesSuccessAndJoinsFailure(t *testing.T) {
if got := attachUnlock(nil, errors.New("unlock"), "get", "notes/x.md"); got != nil {
t.Fatalf("success became %s", got.Code)
}
conf := conflict("exists", "create", "notes/x.md", "retry", nil)
got := attachUnlock(conf, errors.New("unlock"), "create", "notes/x.md")
if got.Code != "exists" {
t.Fatalf("primary failure replaced with %s", got.Code)
}
if got.Detail["unlock"] != "unlock" {
t.Fatalf("unlock detail = %v", got.Detail)
}
if err := attachUnlockErr(nil, errors.New("unlock"), "register", "box"); err != nil {
t.Fatalf("successful register became %v", err)
}
}

func TestUnlockAfterLandedPutIsWarningNotConflict(t *testing.T) {
f := newFixture(t)
releaseHook = func() error { return errors.New("injected unlock") }
t.Cleanup(func() { releaseHook = nil })
res, conf := f.put("hosta", "notes/unlock.md", mkNote("note", "landed"))
if conf != nil {
t.Fatalf("landed put became %s", conf.Code)
}
found := false
for _, w := range res.Warnings {
if w.Rule == "unlock_failed" {
found = true
}
}
if !found {
t.Fatalf("want unlock_failed warning, got %+v", res.Warnings)
}
got, gconf := Get(f.cs, "hosta", "notes/unlock.md")
if gconf != nil {
t.Fatal(gconf.Code)
}
if !strings.Contains(got.Content, "landed") {
t.Fatalf("get missed landed bytes: %q", got.Content)
}
}

func TestUnlockAfterSnapshotDoesNotSkipGet(t *testing.T) {
f := newFixture(t)
if _, conf := f.put("hosta", "notes/visible.md", mkNote("note", "visible")); conf != nil {
t.Fatal(conf.Code)
}
releaseHook = func() error { return errors.New("injected unlock") }
t.Cleanup(func() { releaseHook = nil })
got, conf := Get(f.cs, "hosta", "notes/visible.md")
if conf != nil {
t.Fatalf("get skipped snapshot: %s", conf.Code)
}
if !strings.Contains(got.Content, "visible") {
t.Fatalf("get missed snapshot bytes: %q", got.Content)
}
}
4 changes: 3 additions & 1 deletion modules.budget
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
# Complexity ratchet: split Lint/index/GetMany/sync helpers and takeaways
# scanner so gocognit 16 and nestif 5 can fail closed. Unlock/cleanup
# errors join the primary failure instead of blank assignment.
kernel internal/kernel 57700
# Unlock-after-success: do not convert a landed write or captured snapshot
# into lock_failed; tests inject release failure.
kernel internal/kernel 58400
# FlagSet-driven splitArgs: VisitAll replaces per-command value-flag maps.
cli internal/cli 16200
fm internal/fm 8100
Expand Down
Loading