feat(v0.4): add lease freshness check and stale cleanup
This commit is contained in:
@@ -2,6 +2,7 @@ package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
@@ -113,3 +114,44 @@ func NewLease(startedAt time.Time, agent, mode string) *Lease {
|
||||
Terminal: currentTerminal(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsFresh returns true if the lease's last heartbeat is within threshold
|
||||
// of now. A lease with a zero LastHeartbeatAt is treated as stale.
|
||||
func IsFresh(l *Lease, now time.Time, threshold time.Duration) bool {
|
||||
if l == nil || l.LastHeartbeatAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return now.Sub(l.LastHeartbeatAt) <= threshold
|
||||
}
|
||||
|
||||
// CleanupStaleLease inspects the lease at path:
|
||||
// - missing file: no-op, returns (nil, nil)
|
||||
// - corrupt / unparseable: removes the file, returns (nil, nil)
|
||||
// - stale (heartbeat older than staleAfter): removes the file, returns the parsed lease
|
||||
// - fresh: no-op, returns (nil, nil)
|
||||
//
|
||||
// An I/O error during removal is returned to the caller along with the
|
||||
// parsed lease (if any), so the caller can log it explicitly.
|
||||
func CleanupStaleLease(path string, staleAfter time.Duration) (*Lease, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var l Lease
|
||||
if jsonErr := json.Unmarshal(data, &l); jsonErr != nil {
|
||||
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
||||
return nil, rmErr
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if IsFresh(&l, time.Now(), staleAfter) {
|
||||
return nil, nil
|
||||
}
|
||||
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
||||
return &l, rmErr
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
@@ -139,3 +139,101 @@ func TestNewLeasePopulatesIdentity(t *testing.T) {
|
||||
t.Errorf("SessionID %q should contain hostname %q", l.SessionID, l.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFresh(t *testing.T) {
|
||||
now := time.Date(2026, 4, 21, 14, 30, 22, 0, time.UTC)
|
||||
fresh := &Lease{LastHeartbeatAt: now.Add(-10 * time.Second)}
|
||||
stale := &Lease{LastHeartbeatAt: now.Add(-90 * time.Second)}
|
||||
|
||||
if !IsFresh(fresh, now, 60*time.Second) {
|
||||
t.Error("expected fresh lease to be fresh")
|
||||
}
|
||||
if IsFresh(stale, now, 60*time.Second) {
|
||||
t.Error("expected stale lease to be stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleLeaseRemovesStale(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".ctask", "session.json")
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
|
||||
old := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
|
||||
stale := &Lease{
|
||||
SessionID: "h-1-x",
|
||||
Hostname: "h",
|
||||
StartedAt: old,
|
||||
LastHeartbeatAt: old,
|
||||
}
|
||||
if err := WriteLease(path, stale); err != nil {
|
||||
t.Fatalf("seed WriteLease: %v", err)
|
||||
}
|
||||
|
||||
removed, err := CleanupStaleLease(path, 60*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupStaleLease: %v", err)
|
||||
}
|
||||
if removed == nil {
|
||||
t.Fatal("expected removed lease to be returned")
|
||||
}
|
||||
if removed.Hostname != "h" {
|
||||
t.Errorf("removed lease hostname: got %q", removed.Hostname)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("lease file should be removed after stale cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleLeaseKeepsFresh(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".ctask", "session.json")
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
fresh := &Lease{Hostname: "h", LastHeartbeatAt: now}
|
||||
if err := WriteLease(path, fresh); err != nil {
|
||||
t.Fatalf("seed WriteLease: %v", err)
|
||||
}
|
||||
|
||||
removed, err := CleanupStaleLease(path, 60*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupStaleLease: %v", err)
|
||||
}
|
||||
if removed != nil {
|
||||
t.Errorf("fresh lease should not be removed, got %+v", removed)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("fresh lease file should still exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleLeaseMissingIsNoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".ctask", "session.json")
|
||||
|
||||
removed, err := CleanupStaleLease(path, 60*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("missing lease should be a no-op, got err=%v", err)
|
||||
}
|
||||
if removed != nil {
|
||||
t.Errorf("missing lease: got %+v, want nil", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleLeaseCorruptRemoves(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".ctask", "session.json")
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
os.WriteFile(path, []byte("not json"), 0644)
|
||||
|
||||
removed, err := CleanupStaleLease(path, 60*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupStaleLease should swallow parse errors: %v", err)
|
||||
}
|
||||
if removed != nil {
|
||||
t.Errorf("corrupt lease should not return a parsed lease, got %+v", removed)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("corrupt lease file should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user