Skip to content
Open
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
97 changes: 97 additions & 0 deletions internal/sessions/filelock_process_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package sessions

import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"strings"
"testing"
"time"
)

// TestSessionFileLockBlocksAnotherProcessUntilItExits proves the OS session lock
// (flock on unix, LockFileEx on Windows) serializes mutations across processes,
// not just across Store instances in one process, and that it is released when
// the holder exits without unlocking.
func TestSessionFileLockBlocksAnotherProcessUntilItExits(t *testing.T) {
root := t.TempDir()
store := NewStore(StoreOptions{RootDir: root})
if _, err := store.Create(CreateInput{SessionID: "s"}); err != nil {
t.Fatal(err)
}

cmd := exec.Command(os.Args[0], "-test.run=^TestSessionFileLockHelperProcess$")
cmd.Env = append(os.Environ(), "ZERO_SESSIONS_LOCK_HELPER=1", "ZERO_SESSIONS_LOCK_ROOT="+root)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
exited := false
t.Cleanup(func() {
if !exited {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
})

ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "ready" {
t.Fatalf("helper readiness = %q, err %v", ready, err)
}

// The helper process holds the session lock, so this must block.
done := make(chan error, 1)
go func() {
_, appendErr := store.AppendEvent("s", AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
done <- appendErr
}()
select {
case err := <-done:
t.Fatalf("AppendEvent completed while another process held the session lock (err=%v)", err)
case <-time.After(300 * time.Millisecond):
// Expected: still blocked.
}

// The helper exits without unlocking; the OS must release the lock for us.
if err := stdin.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("helper exit: %v", err)
}
exited = true
select {
case err := <-done:
if err != nil {
t.Fatalf("AppendEvent after the holder exited: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("AppendEvent did not proceed after the lock holder exited")
}
}

func TestSessionFileLockHelperProcess(t *testing.T) {
if os.Getenv("ZERO_SESSIONS_LOCK_HELPER") != "1" {
return
}
store := NewStore(StoreOptions{RootDir: os.Getenv("ZERO_SESSIONS_LOCK_ROOT")})
if _, err := store.lockSession("s"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if _, err := fmt.Fprintln(os.Stdout, "ready"); err != nil {
os.Exit(3)
}
_, _ = io.Copy(io.Discard, os.Stdin)
// Deliberately exit without calling the unlock function.
}
3 changes: 0 additions & 3 deletions internal/sessions/rewind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,6 @@ func TestCaptureAndPruneConcurrent(t *testing.T) {
// separate Store instances on the same RootDir (e.g. CLI rewind vs TUI). While
// one Store holds the session lock, another Store's AppendEvent must block.
func TestSessionFileLockSerializesAcrossStores(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("flock semantics differ on windows")
}
root := t.TempDir()
storeA := NewStore(StoreOptions{RootDir: root})
if _, err := storeA.Create(CreateInput{SessionID: "s"}); err != nil {
Expand Down
Loading