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
6 changes: 6 additions & 0 deletions daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ databases, command parsing, and shutdown policy belong to the caller.
- Use listen locks to serialize startup and bind attempts.
- Hold the owner lock for the daemon's full writable lifetime; use the start
lock only to serialize discovery, replacement, and launch decisions.
- Keep blocking and nonblocking start-lock acquisition on the same local
semaphore and file lock. Contention is authoritative; an acquisition error
means unknown state. Application snapshots never override a held lock.
- Callers own startup snapshots and cleanup policy. Cleanup must finish while
holding the start lock. Preserve the lock file so all holders use the same
filesystem object.
- Route every `Manager.Ensure` lookup through `Manager.Find` so a caller's
`FindFunc` is used for initial discovery, locked re-discovery, and polling.
- Keep platform-specific behavior in build-tagged files when ownership, sockets,
Expand Down
11 changes: 11 additions & 0 deletions daemon/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@
// callers need custom probing while retaining its start locking, re-discovery,
// polling, and timeout behavior.
//
// RuntimeStore.TryAcquireStartLock supports nonblocking startup markers and
// probes. A successful caller holds the same lock as Manager.Ensure until it
// invokes the release function. Callers may inspect and clean up their own
// startup state before releasing. Contention includes holders in this process;
// callers that need to recognize their own marker must synchronize acquisition,
// registration of the release function, probing, and release themselves.
// Acquisition errors leave the state unknown. A caller may retry after resolving
// an error, but only successful acquisition authorizes cleanup or launch.
// Snapshot age, process identity checks, and retry policy belong to the caller.
// A released probe grants no reservation for a subsequent launch.
//
// Authenticated discovery must prove an endpoint before any bearer credential
// is sent. Servers construct a Proof from the shared daemon token and register
// its NewPingHandler result using the same RuntimeRecord they write to
Expand Down
66 changes: 60 additions & 6 deletions daemon/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,51 @@ func (s RuntimeStore) AcquireStartLock(ctx context.Context) (func(), error) {
return acquireDaemonLock(ctx, path, "acquire daemon start lock")
}

// TryAcquireStartLock attempts to acquire the same lock as AcquireStartLock
// without waiting for a holder. On success, acquired is true and the caller
// must call release exactly once, after any application-owned state cleanup.
// With contention, including another goroutine in this process, it returns
// nil, false, nil. An error means the lock state could not be determined.
//
// For a probe, release immediately after acquisition. The result is advisory
// once released; acquire again before making a launch decision. This method
// does not identify the holder, inspect startup snapshots, retry errors, or
// remove lock files. Callers that distinguish their own startup from other
// holders must track their retained release function themselves.
//
// The context is checked before acquisition; filesystem operations themselves
// are synchronous and cannot be interrupted by cancellation.
func (s RuntimeStore) TryAcquireStartLock(ctx context.Context) (release func(), acquired bool, err error) {
const action = "try acquire daemon start lock"
if err := ctx.Err(); err != nil {
return nil, false, fmt.Errorf("%s: %w", action, err)
}
path, err := s.LockPath()
if err != nil {
return nil, false, err
}
lock, err := getDaemonLock(path, action)
if err != nil {
return nil, false, err
}
if !lock.local.TryAcquire(1) {
return nil, false, nil
}
if err := ctx.Err(); err != nil {
lock.local.Release(1)
return nil, false, fmt.Errorf("%s: %w", action, err)
}
locked, err := lock.file.TryLock()
if err != nil || !locked {
lock.local.Release(1)
if err != nil {
return nil, false, fmt.Errorf("%s: %w", action, err)
}
return nil, false, nil
}
return lock.release, true, nil
}

// AcquireOwnerLock grants exclusive writable ownership for the lifetime of a
// daemon. The caller must retain the lock until server teardown is complete.
func (s RuntimeStore) AcquireOwnerLock(ctx context.Context) (func(), error) {
Expand All @@ -46,7 +91,7 @@ func (s RuntimeStore) AcquireOwnerLock(ctx context.Context) (func(), error) {
return acquireDaemonLock(ctx, path, "acquire daemon owner lock")
}

func acquireDaemonLock(ctx context.Context, lockPath, action string) (func(), error) {
func getDaemonLock(lockPath, action string) (*daemonLock, error) {
if lockPath == "" {
return nil, fmt.Errorf("%s: empty daemon lock path", action)
}
Expand All @@ -60,7 +105,14 @@ func acquireDaemonLock(ctx context.Context, lockPath, action string) (func(), er
local: semaphore.NewWeighted(1),
file: flock.New(lockPath),
})
lock := value.(*daemonLock)
return value.(*daemonLock), nil
}

func acquireDaemonLock(ctx context.Context, lockPath, action string) (func(), error) {
lock, err := getDaemonLock(lockPath, action)
if err != nil {
return nil, err
}
if err := lock.local.Acquire(ctx, 1); err != nil {
return nil, fmt.Errorf("%s: %w", action, err)
}
Expand All @@ -76,8 +128,10 @@ func acquireDaemonLock(ctx context.Context, lockPath, action string) (func(), er
}
return nil, errors.New(action + ": lock not acquired")
}
return func() {
_ = lock.file.Unlock()
lock.local.Release(1)
}, nil
return lock.release, nil
}

func (lock *daemonLock) release() {
_ = lock.file.Unlock()
lock.local.Release(1)
}
171 changes: 171 additions & 0 deletions daemon/lock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package daemon_test

import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/kit/daemon"
)

func TestTryAcquireStartLockCoordinatesWithManager(t *testing.T) {
require := require.New(t)
store := daemon.RuntimeStore{Dir: t.TempDir()}
release, acquired, err := store.TryAcquireStartLock(t.Context())
require.NoError(err)
require.True(acquired)
require.NotNil(release)
// Keep cleanup valid even if a precondition below fails.
defer func() {
if release != nil {
release()
}
}()

again, acquired, err := store.TryAcquireStartLock(t.Context())
require.NoError(err)
require.False(acquired)
require.Nil(again)
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err = store.AcquireStartLock(ctx)
require.ErrorIs(err, context.Canceled)

snapshot := filepath.Join(store.Dir, "progress.json")
require.NoError(os.WriteFile(snapshot, []byte("old progress"), 0o600))
ready := false
manager := daemon.Manager{
Store: store,
FindFunc: func(context.Context) (daemon.RuntimeRecord, daemon.PingInfo, bool, error) {
return daemon.RuntimeRecord{}, daemon.PingInfo{}, ready, nil
},
Start: func(ctx context.Context) error {
// Manager owns the same local lock while invoking Start.
probeRelease, acquired, err := store.TryAcquireStartLock(ctx)
assert.NoError(t, err)
assert.False(t, acquired)
assert.Nil(t, probeRelease)
assert.NoFileExists(t, snapshot)
ready = true
return nil
},
}
ctx, cancel = context.WithTimeout(t.Context(), 20*time.Millisecond)
defer cancel()
_, _, err = manager.Ensure(ctx, time.Second)
require.ErrorIs(err, context.DeadlineExceeded)
require.False(ready)
require.NoError(os.Remove(snapshot)) // Caller-owned cleanup happens under lock.
release()
release = nil
_, _, err = manager.Ensure(t.Context(), time.Second)
require.NoError(err)
require.True(ready)
release, acquired, err = store.TryAcquireStartLock(t.Context())
require.NoError(err)
require.True(acquired)
}

func TestTryAcquireStartLockErrorAndRetry(t *testing.T) {
require := require.New(t)
store := daemon.RuntimeStore{Dir: t.TempDir()}
path, err := store.LockPath()
require.NoError(err)
// Windows rejects opening a directory here. Unix permits locking one,
// so use an unreadable file to exercise its open error instead.
if runtime.GOOS == "windows" {
require.NoError(os.Mkdir(path, 0o700))
} else {
if os.Geteuid() == 0 {
t.Skip("root can open mode-000 files")
}
require.NoError(os.WriteFile(path, nil, 0o000))
}
release, acquired, err := store.TryAcquireStartLock(t.Context())
require.Error(err)
require.Nil(release)
require.False(acquired)
require.NoError(os.Remove(path))
release, acquired, err = store.TryAcquireStartLock(t.Context())
require.NoError(err)
require.True(acquired)
require.NotNil(release)
release()
}

func TestTryAcquireStartLockCanceled(t *testing.T) {
require := require.New(t)
store := daemon.RuntimeStore{Dir: t.TempDir()}
ctx, cancel := context.WithCancel(t.Context())
cancel()
release, acquired, err := store.TryAcquireStartLock(ctx)
require.ErrorIs(err, context.Canceled)
require.Nil(release)
require.False(acquired)
release, acquired, err = store.TryAcquireStartLock(t.Context())
require.NoError(err)
require.True(acquired)
release()
}

func TestTryAcquireStartLockAfterHolderExit(t *testing.T) {
require := require.New(t)
store := daemon.RuntimeStore{Dir: t.TempDir()}
exe, err := os.Executable()
require.NoError(err)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestStartLockProcess$", "--", store.Dir)
stdout, err := cmd.StdoutPipe()
require.NoError(err)
stdin, err := cmd.StdinPipe()
require.NoError(err)
defer stdin.Close()
require.NoError(cmd.Start())
waited := false
defer func() {
if !waited {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
}()
line, err := bufio.NewReader(stdout).ReadString('\n')
require.NoError(err)
require.Equal("locked\n", line)
release, acquired, err := store.TryAcquireStartLock(t.Context())
require.NoError(err)
require.False(acquired)
require.Nil(release)
require.NoError(cmd.Process.Kill())
require.Error(cmd.Wait())
waited = true
path, err := store.LockPath()
require.NoError(err)
require.FileExists(path)
release, acquired, err = store.TryAcquireStartLock(t.Context())
require.NoError(err)
require.True(acquired, "a terminated holder's remaining file does not block acquisition")
require.NotNil(release)
release()
}

func TestStartLockProcess(t *testing.T) {
if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "--" {
return
}
store := daemon.RuntimeStore{Dir: os.Args[len(os.Args)-1]}
release, err := store.AcquireStartLock(t.Context())
require.NoError(t, err)
defer release()
_, err = os.Stdout.WriteString("locked\n")
require.NoError(t, err)
var buf [1]byte
_, _ = os.Stdin.Read(buf[:])
}