Skip to content
Merged
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
22 changes: 22 additions & 0 deletions docs/changes/unreleased/2017-home-owned-furrow-sockets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
kind: fixed
title: Each codeaf home has its own private saving connection
pr: 2017
surface: [chat, engine, docs]
invalidates:
- "Different codeaf homes on Windows could use the same saving daemon because its socket named only the engine build. The address now identifies both the home and build."
- "A long home on Unix trusted a predictable temporary socket without checking its directory or owner. Short endpoints now use validated private directories and reject preclaimed or foreign endpoints before dialing or pruning."
- "Windows could repeatedly fail every seal when no secure endpoint fit the address limit. Saved file history now turns off with one plain sentence and tools keep working."
- "An unset HOME or the original ancestor of a symlinked home could bypass the home-folder policy. Both now seal only the chat's own folder."
---

Same-home clients keep sharing their saving daemon, also across an upgrade.
Unix keeps a fitting in-home address; a long home keeps the short temporary
address earlier engines used while it is this user's private directory, and only
a preclaimed one falls to the user's private runtime directory or other
validated private temporary directories. Windows falls back only under the user's own temporary
folder with a private ACL; final addresses stay below the platform byte limit.
Stale endpoints are pruned only while holding their daemon's lifetime lock,
inside the existing per-socket start gate. A lost answer is never replayed and
the next seal reconnects as before. The home-folder policy and its sentence stay
the same; ordinary project folders retain saved history and rewind.
9 changes: 9 additions & 0 deletions internal/cellstore/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,22 @@ func exchange(conn net.Conn, req wireRequest) (wireResponse, error) {

// connect dials the daemon, starting it when nothing listens.
func (d Daemon) connect(ctx context.Context) (net.Conn, error) {
if d.Socket == "" {
return nil, fmt.Errorf("no private saving-engine socket directory fits the address limit")
}
if err := validateSocket(d.Socket); err != nil {
return nil, err
}
if conn, err := dial(ctx, d.Socket); err == nil {
return conn, nil
}
return startAndDial(ctx, d)
}

func dial(ctx context.Context, socket string) (net.Conn, error) {
if err := validateSocket(socket); err != nil {
return nil, err
}
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", socket)
}
4 changes: 4 additions & 0 deletions internal/cellstore/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ func TestDaemonOfANonEngineIsUnavailable(t *testing.T) {

// Two engine builds never share a daemon: the socket is named after the binary.
func TestSocketIsKeyedToTheEngineBinary(t *testing.T) {
base := filepath.Dir(testSocket(t))
t.Setenv("CODEAF_HOME", base)
t.Setenv("LOCALAPPDATA", base)
t.Setenv("XDG_RUNTIME_DIR", "")
a := socketFor("/x/furrow-0.1.0-aaaaaaaaaaaa")
b := socketFor("/x/furrow-0.1.0-bbbbbbbbbbbb")
if a == b || (runtime.GOOS != "windows" && filepath.Base(a) != "engine-0.1.0-aaaaaaaaaaaa.sock") {
Expand Down
55 changes: 55 additions & 0 deletions internal/cellstore/daemoncleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cellstore

import (
"context"
"os"
"path/filepath"
"strings"
"time"

"github.com/Agent-Field/codeaf/internal/filelock"
)

// pruneStaleSockets only visits the managed socket directory on a cold start.
// Furrow holds socket.lock for its lifetime, including before it listens.
func pruneStaleSockets(ctx context.Context, dir string) {
entries, _ := os.ReadDir(dir)
for _, entry := range entries {
if ctx.Err() != nil {
return
}
name := entry.Name()
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.HasSuffix(name, ".sock") || (!strings.HasPrefix(name, "e-") && !strings.HasPrefix(name, "engine-")) {
continue
}
pruneStaleSocket(ctx, filepath.Join(dir, name))
}
}

func pruneStaleSocket(ctx context.Context, path string) {
if validateSocket(path) != nil {
return
}
lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return
}
defer lock.Close()
if filelock.Lock(lock, true, true) != nil {
return
}
defer filelock.Unlock(lock)
// An older listener without a lock is live too. A timed-out probe is
// inconclusive, so it keeps its endpoint rather than risking a live client.
probe, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
conn, err := dial(probe, path)
if err == nil {
conn.Close()
return
}
if probe.Err() == nil {
_ = os.Remove(path)
}
// Never unlink the lock: a concurrent starter may already have it open.
}
67 changes: 67 additions & 0 deletions internal/cellstore/daemoncleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package cellstore

import (
"net"
"os"
"path/filepath"
"testing"

"github.com/Agent-Field/codeaf/internal/filelock"
)

// TestW3DaemonStartPrunesOnlyDeadSockets proves B3 through a real daemon start:
// abandoned endpoints go; live listeners, held locks and unrelated files stay.
func TestW3DaemonStartPrunesOnlyDeadSockets(t *testing.T) {
e := realEngine(t)
d := daemonFor(t, e.Binary).(Daemon)
e.Transport = d
dir := filepath.Dir(d.Socket)
stale := filepath.Join(dir, "e-abandoned.sock")
listener, err := net.Listen("unix", stale)
if err != nil {
t.Fatal(err)
}
listener.(*net.UnixListener).SetUnlinkOnClose(false)
if err := listener.Close(); err != nil {
t.Fatal(err)
}
livePath := filepath.Join(dir, "e-live.sock")
live, err := net.Listen("unix", livePath)
if err != nil {
t.Fatal(err)
}
defer live.Close()
locked := filepath.Join(dir, "e-starting.sock")
lock, err := os.OpenFile(locked+".lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
t.Fatal(err)
}
defer lock.Close()
if err := filelock.Lock(lock, true, true); err != nil {
t.Fatal(err)
}
defer filelock.Unlock(lock)
for _, name := range []string{"e-regular.sock", "e-starting.sock", "keep.sock", "e-copy.sock.bak"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("fixture"), 0600); err != nil {
t.Fatal(err)
}
}
if _, err := e.Seal(t.Context(), newCell(t), TurnInfo{}); err != nil {
t.Fatal(err)
}
for _, name := range []string{"e-abandoned.sock", "e-regular.sock"} {
if _, err := os.Lstat(filepath.Join(dir, name)); !os.IsNotExist(err) {
t.Errorf("dead endpoint %s remains: %v", name, err)
}
}
for _, name := range []string{"e-live.sock", "e-starting.sock", "e-starting.sock.lock", "keep.sock", "e-copy.sock.bak"} {
if _, err := os.Lstat(filepath.Join(dir, name)); err != nil {
t.Errorf("active or unrelated %s removed: %v", name, err)
}
}
conn, err := net.Dial("unix", livePath)
if err != nil {
t.Fatalf("cleanup broke a live endpoint: %v", err)
}
conn.Close()
}
155 changes: 155 additions & 0 deletions internal/cellstore/daemonhome_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cellstore

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/cell"
)

// TestW3SocketIdentityIncludesHomeAndBuild proves B1, including equivalent
// absolute home spellings and symlink aliases where the platform allows them.
func TestW3SocketIdentityIncludesHomeAndBuild(t *testing.T) {
first := t.TempDir()
second := t.TempDir()
bin := "/engine/furrow-0.2.0-aaaaaaaaaaaa"
t.Setenv("LOCALAPPDATA", filepath.Dir(testSocket(t)))
t.Setenv("CODEAF_HOME", first)
a := socketFor(bin)
if a != socketFor(bin) || a == socketFor("/engine/furrow-0.2.0-bbbbbbbbbbbb") {
t.Fatal("same home/build must share; distinct builds must not")
}
t.Setenv("CODEAF_HOME", second)
if a == socketFor(bin) {
t.Fatal("distinct engine homes shared a socket")
}
relative, err := filepath.Rel(mustWorkingDir(t), first)
if err != nil {
t.Fatal(err)
}
t.Setenv("CODEAF_HOME", relative)
if a != socketFor(bin) {
t.Fatal("relative and absolute spellings of one home did not share")
}
alias := filepath.Join(t.TempDir(), "alias")
if err := os.Symlink(first, alias); err == nil {
t.Setenv("CODEAF_HOME", alias)
if a != socketFor(bin) {
t.Fatal("home symlink alias did not share its daemon")
}
} else {
t.Logf("symlink alias unavailable: %v", err)
}
}

func mustWorkingDir(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return dir
}

// TestW3HomeDaemonsSealConcurrentlyAndRestart proves B1/B2 with real furrow:
// different homes seal concurrently; same-home clients share; a killed daemon
// restarts at the next seal without disrupting the other home.
func TestW3HomeDaemonsSealConcurrentlyAndRestart(t *testing.T) {
bin, err := sealingEngine()
if err != nil {
t.Skipf("no engine binary: %v", err)
}
t.Setenv("LOCALAPPDATA", filepath.Dir(testSocket(t)))
engines := make([]Engine, 2)
daemons := make([]Daemon, 2)
cells := []cell.Cell{newCell(t), newCell(t)}
for i := range engines {
t.Setenv("CODEAF_HOME", t.TempDir())
e := engineFor(t, (Engine{}).Root())
e.Binary, e.Workspace = bin, t.TempDir()
e.Transport = transportFor(bin, nil)
engines[i] = e
daemons[i] = Daemon{Socket: socketFor(bin), Binary: bin}
d := daemons[i]
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), SealWithin)
defer cancel()
if err := d.Stop(ctx); err != nil {
t.Errorf("stop home daemon: %v", err)
}
})
}
if daemons[0].Socket == daemons[1].Socket {
t.Fatal("two engines share one socket")
}
errors := make(chan error, 2)
for i, e := range engines {
go func() {
sealed, err := e.Seal(t.Context(), cells[i], TurnInfo{})
if err == nil && sealed.Turn.ID == "" {
err = os.ErrInvalid
}
errors <- err
}()
}
for range engines {
if err := <-errors; err != nil {
t.Fatal(err)
}
}
pids := []int{homeDaemonPID(t, daemons[0]), homeDaemonPID(t, daemons[1])}
if pids[0] == pids[1] {
t.Fatal("distinct homes used the same daemon process")
}
if _, err := engines[0].Seal(t.Context(), newCell(t), TurnInfo{}); err != nil {
t.Fatal(err)
}
if homeDaemonPID(t, daemons[0]) != pids[0] {
t.Fatal("same-home clients restarted instead of sharing")
}
exited, ok := daemonExits.Load(daemons[0].Socket)
if !ok {
t.Fatal("test's daemon has no owned exit channel")
}
process, err := os.FindProcess(pids[0])
if err != nil {
t.Fatal(err)
}
if err := process.Kill(); err != nil {
t.Fatal(err)
}
_ = process.Release()
select {
case <-exited.(<-chan struct{}):
case <-time.After(5 * time.Second):
t.Fatal("killed daemon did not exit")
}
if _, err := engines[0].Seal(t.Context(), cells[0], TurnInfo{}); err != nil {
t.Fatalf("next seal did not restart the dead daemon: %v", err)
}
if homeDaemonPID(t, daemons[0]) == pids[0] || homeDaemonPID(t, daemons[1]) != pids[1] {
t.Fatal("restart did not stay within its own home")
}
}

func homeDaemonPID(t *testing.T, d Daemon) int {
t.Helper()
conn, err := dial(t.Context(), d.Socket)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
response, err := exchange(conn, wireRequest{V: wireVersion, ID: nextID.Add(1), Verb: "health"})
if err != nil {
t.Fatal(err)
}
var health struct{ PID int }
if err := json.Unmarshal(response.Ok, &health); err != nil || health.PID == 0 {
t.Fatalf("no daemon pid: %s %v", response.Ok, err)
}
return health.PID
}
Loading
Loading