diff --git a/internal/session/lease.go b/internal/session/lease.go index 4b57324..7d0257e 100644 --- a/internal/session/lease.go +++ b/internal/session/lease.go @@ -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 +} diff --git a/internal/session/lease_test.go b/internal/session/lease_test.go index 8a73ba4..e86927c 100644 --- a/internal/session/lease_test.go +++ b/internal/session/lease_test.go @@ -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") + } +}