feat(v0.4): add lease freshness check and stale cleanup

This commit is contained in:
2026-04-21 17:03:21 -04:00
parent c29985b663
commit bc5410f722
2 changed files with 140 additions and 0 deletions
+42
View File
@@ -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
}
+98
View File
@@ -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")
}
}