184 lines
5.4 KiB
Go
184 lines
5.4 KiB
Go
package control
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/store"
|
|
)
|
|
|
|
func TestSessionLeaseKeeperRebindMovesLease(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := filepath.Join(dir, "a.jsonl")
|
|
b := filepath.Join(dir, "b.jsonl")
|
|
|
|
k := NewSessionLeaseKeeper()
|
|
defer k.Release()
|
|
|
|
if err := k.Rebind(a); err != nil {
|
|
t.Fatalf("Rebind(a): %v", err)
|
|
}
|
|
if got, want := k.HeldPath(), agent.CanonicalSessionPath(a); got != want {
|
|
t.Fatalf("HeldPath = %q, want %q", got, want)
|
|
}
|
|
if _, err := os.Stat(store.SessionLeaseInfo(agent.CanonicalSessionPath(a))); err != nil {
|
|
t.Fatalf("lease info for a missing: %v", err)
|
|
}
|
|
// a is held: an outside acquire must fail.
|
|
if _, err := agent.TryAcquireSessionLease(a); !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
t.Fatalf("TryAcquireSessionLease(a) while kept = %v, want ErrSessionLeaseHeld", err)
|
|
}
|
|
|
|
if err := k.Rebind(b); err != nil {
|
|
t.Fatalf("Rebind(b): %v", err)
|
|
}
|
|
if got, want := k.HeldPath(), agent.CanonicalSessionPath(b); got != want {
|
|
t.Fatalf("HeldPath after rebind = %q, want %q", got, want)
|
|
}
|
|
// The old lease is released: a is acquirable again and its info is gone.
|
|
if _, err := os.Stat(store.SessionLeaseInfo(agent.CanonicalSessionPath(a))); !os.IsNotExist(err) {
|
|
t.Fatalf("lease info for a after rebind stat err = %v, want not exist", err)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(a)
|
|
if err != nil {
|
|
t.Fatalf("TryAcquireSessionLease(a) after rebind: %v", err)
|
|
}
|
|
lease.Release()
|
|
}
|
|
|
|
func TestSessionLeaseKeeperRebindSamePathIsNoop(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := filepath.Join(dir, "a.jsonl")
|
|
|
|
k := NewSessionLeaseKeeper()
|
|
defer k.Release()
|
|
if err := k.Rebind(a); err != nil {
|
|
t.Fatalf("Rebind(a): %v", err)
|
|
}
|
|
// Same canonical path again must not trip over the keeper's own lease.
|
|
if err := k.Rebind(a); err != nil {
|
|
t.Fatalf("Rebind(a) again: %v", err)
|
|
}
|
|
if got, want := k.HeldPath(), agent.CanonicalSessionPath(a); got != want {
|
|
t.Fatalf("HeldPath = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSessionLeaseKeeperRefusesHeldPathAndKeepsCurrent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := filepath.Join(dir, "a.jsonl")
|
|
b := filepath.Join(dir, "b.jsonl")
|
|
|
|
holder, err := agent.TryAcquireSessionLease(b)
|
|
if err != nil {
|
|
t.Fatalf("holder acquire: %v", err)
|
|
}
|
|
defer holder.Release()
|
|
|
|
k := NewSessionLeaseKeeper()
|
|
defer k.Release()
|
|
if err := k.Rebind(a); err != nil {
|
|
t.Fatalf("Rebind(a): %v", err)
|
|
}
|
|
err = k.Rebind(b)
|
|
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
t.Fatalf("Rebind(held b) = %v, want ErrSessionLeaseHeld", err)
|
|
}
|
|
// Failure leaves the keeper on its previous session.
|
|
if got, want := k.HeldPath(), agent.CanonicalSessionPath(a); got != want {
|
|
t.Fatalf("HeldPath after refused rebind = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSessionLeaseKeeperEmptyPathReleases(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := filepath.Join(dir, "a.jsonl")
|
|
|
|
k := NewSessionLeaseKeeper()
|
|
defer k.Release()
|
|
if err := k.Rebind(a); err != nil {
|
|
t.Fatalf("Rebind(a): %v", err)
|
|
}
|
|
if err := k.Rebind(""); err != nil {
|
|
t.Fatalf("Rebind(empty): %v", err)
|
|
}
|
|
if got := k.HeldPath(); got != "" {
|
|
t.Fatalf("HeldPath after empty rebind = %q, want empty", got)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(a)
|
|
if err != nil {
|
|
t.Fatalf("TryAcquireSessionLease(a) after empty rebind: %v", err)
|
|
}
|
|
lease.Release()
|
|
}
|
|
|
|
func TestSessionLeaseKeeperReleaseRemovesLeaseInfo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := filepath.Join(dir, "a.jsonl")
|
|
|
|
k := NewSessionLeaseKeeper()
|
|
if err := k.Rebind(a); err != nil {
|
|
t.Fatalf("Rebind(a): %v", err)
|
|
}
|
|
k.Release()
|
|
k.Release() // idempotent
|
|
if _, err := os.Stat(store.SessionLeaseInfo(agent.CanonicalSessionPath(a))); !os.IsNotExist(err) {
|
|
t.Fatalf("lease info after Release stat err = %v, want not exist", err)
|
|
}
|
|
if got := k.HeldPath(); got != "" {
|
|
t.Fatalf("HeldPath after Release = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestSessionInUseMessageNamesHolder(t *testing.T) {
|
|
acquired := time.Date(2026, 7, 6, 3, 4, 0, 0, time.UTC)
|
|
err := &agent.SessionLeaseError{
|
|
Path: "/tmp/x.jsonl",
|
|
Info: &agent.SessionLeaseInfo{
|
|
SessionPath: "/tmp/x.jsonl",
|
|
WriterID: "writer-nonce-should-not-appear",
|
|
PID: 12345,
|
|
Hostname: "devbox",
|
|
AcquiredAt: acquired,
|
|
},
|
|
}
|
|
msg := SessionInUseMessage(err)
|
|
if !strings.Contains(msg, "another Reasonix process") {
|
|
t.Fatalf("message %q missing holder wording", msg)
|
|
}
|
|
if !strings.Contains(msg, "pid 12345") || !strings.Contains(msg, "on devbox") {
|
|
t.Fatalf("message %q missing pid/host", msg)
|
|
}
|
|
if !strings.Contains(msg, "since "+acquired.Local().Format("15:04")) {
|
|
t.Fatalf("message %q missing local acquire time", msg)
|
|
}
|
|
if strings.Contains(msg, "writer-nonce-should-not-appear") {
|
|
t.Fatalf("message %q leaks the writer id", msg)
|
|
}
|
|
if strings.Contains(msg, "/tmp/x.jsonl") {
|
|
t.Fatalf("message %q leaks the session path", msg)
|
|
}
|
|
}
|
|
|
|
func TestSessionInUseMessageFallsBackWithoutInfo(t *testing.T) {
|
|
for name, err := range map[string]error{
|
|
"nil info": &agent.SessionLeaseError{Path: "/tmp/x.jsonl"},
|
|
"plain held": agent.ErrSessionLeaseHeld,
|
|
"zero pid": &agent.SessionLeaseError{Info: &agent.SessionLeaseInfo{PID: 0}},
|
|
} {
|
|
msg := SessionInUseMessage(err)
|
|
if msg != "this session is in use by another Reasonix window or process" {
|
|
t.Fatalf("%s: message = %q, want generic fallback", name, msg)
|
|
}
|
|
if strings.Contains(msg, "pid "+strconv.Itoa(os.Getpid())) {
|
|
t.Fatalf("%s: fallback should not invent a pid: %q", name, msg)
|
|
}
|
|
}
|
|
}
|