watch_test.go
package main
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// watchFixture is a server plus a watched config file in a temp dir,
// wired the same way main() wires them (watchConfig -> reloadConfig),
// with a fast poll and a reload-attempt counter for happens-after gating.
// Repo "one" is the standard hermetic fixture repo (so its shas are the fixtureSha* consts);
// repo "two" is a second trivial repo, initially not mounted.
type watchFixture struct {
srv *server
cfgPath string
repoOne string // worktree of the standard fixture repo
repoTwo string // worktree of the second repo
checked atomic.Int64 // completed reload attempts (accepted or rejected)
}
func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture {
t.Helper()
fx := &watchFixture{
repoOne: buildFixtureRepo(t),
repoTwo: t.TempDir(),
cfgPath: filepath.Join(t.TempDir(), "socle.cfg"),
}
gitFixture(t, fx.repoTwo, "init", "-q", "-b", "main", ".")
writeFixtureFile(t, fx.repoTwo, "README.md", "# Two\n")
gitFixture(t, fx.repoTwo, "add", "-A")
gitFixture(t, fx.repoTwo, "commit", "-q", "-m", "inaugurate repo two")
fx.writeConfig(t, fx.configNaming("one"))
cfg, err := loadConfig(fx.cfgPath)
if err != nil {
t.Fatal(err)
}
repos, warnings := openAll(cfg)
if len(warnings) > 0 {
t.Fatalf("fixture repos should all open: %v", warnings)
}
fx.srv = newServer(repos, cfg.BaseURL, testFace(t))
watchConfig(fx.cfgPath, pollInterval, func() {
reloadConfig(fx.srv, fx.cfgPath, "test")
fx.checked.Add(1)
})
return fx
}
// configNaming renders a config body mounting the given fixture repos
// ("one" and/or "two") at those names, by absolute path.
// Rewrites of the config after fixture setup append a size-changing comment:
// the watcher's stat gate compares (mtime, size, inode), and an in-place rewrite
// of identical size within one coarse-mtime granule is legitimately invisible to it
// (tests must not lean on mtime granularity; that is timing, and timing flakes).
func (fx *watchFixture) configNaming(names ...string) string {
paths := map[string]string{"one": fx.repoOne, "two": fx.repoTwo}
var b strings.Builder
b.WriteString("repos:\n")
for _, n := range names {
fmt.Fprintf(&b, " %s:\n path: %s\n", n, paths[n])
}
return b.String()
}
func (fx *watchFixture) writeConfig(t *testing.T, text string) {
t.Helper()
if err := os.WriteFile(fx.cfgPath, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
func (fx *watchFixture) status(target string) int {
return do(fx.srv, target, nil, nil).Code
}
// waitFor polls cond until it holds.
// The timeout is damage control only; a passing test never leans on it.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for: %s", what)
}
time.Sleep(time.Millisecond)
}
}
func TestHotSwapServesNewReposAndDropsOld(t *testing.T) {
fx := newWatchFixture(t, 2*time.Millisecond)
if fx.status("/one/") != http.StatusOK {
t.Fatal("initial repo not serving")
}
fx.writeConfig(t, fx.configNaming("two")+"# swapped\n")
waitFor(t, "new repo to serve", func() bool {
return fx.status("/two/") == http.StatusOK
})
if code := fx.status("/one/"); code != http.StatusNotFound {
t.Errorf("old repo still serving after swap: status = %d", code)
}
}
func TestRejectedReloadKeepsOldReposServing(t *testing.T) {
// A malformed config (here: YAML that does not parse) is rejected wholesale,
// and the previously served repos keep serving untouched.
fx := newWatchFixture(t, 2*time.Millisecond)
before := fx.checked.Load()
fx.writeConfig(t, "repos: [unclosed\n")
// Happens-after gate: wait for a reload attempt to complete,
// then assert it changed nothing.
waitFor(t, "a reload attempt on the bad config", func() bool {
return fx.checked.Load() > before
})
if code := fx.status("/one/"); code != http.StatusOK {
t.Errorf("old config stopped serving after rejected reload: status = %d", code)
}
}
func TestAllReposVanishedKeepsOldReposServing(t *testing.T) {
// A well-formed config whose every repo fails to open (here: a directory
// that is not a git repository) is rejected the same way a parse error is:
// per-repo skips are startup-compatible leniency, but an empty swap would
// take the whole showcase dark, so it is refused.
fx := newWatchFixture(t, 2*time.Millisecond)
before := fx.checked.Load()
fx.writeConfig(t, "repos:\n ghost:\n path: "+t.TempDir()+"\n")
waitFor(t, "a reload attempt on the all-vanished config", func() bool {
return fx.checked.Load() > before
})
if code := fx.status("/one/"); code != http.StatusOK {
t.Errorf("old config stopped serving after all-vanished reload: status = %d", code)
}
}
func TestRenameSwapPickedUpByInotify(t *testing.T) {
// The editor/sync-tool pattern: write a temp file, rename it over the config.
// The poll interval is set absurdly long so that ONLY inotify can pick this up:
// this test deliberately exercises the directory watch
// (a file-level watch would be orphaned by the rename).
fx := newWatchFixture(t, time.Hour)
tmp := fx.cfgPath + ".tmp"
if err := os.WriteFile(tmp, []byte(fx.configNaming("two")+"# swapped\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, fx.cfgPath); err != nil {
t.Fatal(err)
}
waitFor(t, "rename-swapped config to apply", func() bool {
return fx.status("/two/") == http.StatusOK
})
if code := fx.status("/one/"); code != http.StatusNotFound {
t.Errorf("old repo still serving after swap: status = %d", code)
}
}
// firstReadSignaler closes done when its Read is first called.
// In a request flow, the handler dispatches (and loads its repo-set snapshot)
// before anything reads the body, so the close is a happens-after signal
// that the in-flight request already holds its snapshot.
type firstReadSignaler struct {
r io.Reader
done chan struct{}
once atomic.Bool
}
func (f *firstReadSignaler) Read(p []byte) (int, error) {
if f.once.CompareAndSwap(false, true) {
close(f.done)
}
return f.r.Read(p)
}
func TestInFlightRequestKeepsItsSnapshotAcrossSwap(t *testing.T) {
// The swap is atomic from a request's point of view: a fetch RPC that has
// already dispatched keeps the repo set it started with, and completes
// normally even though a reload dropped its repo from the config mid-request.
fx := newWatchFixture(t, 2*time.Millisecond)
// The body is declared gzipped so the handler reads from it (for the gzip header)
// after dispatch but before the fetch RPC starts: the first-read signal thus
// brackets the swap between this request's dispatch and its actual git work.
pr, pw := io.Pipe()
body := &firstReadSignaler{r: pr, done: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, "http://socle.example/one/git-upload-pack", body)
req.Header.Set("Content-Encoding", "gzip")
rec := httptest.NewRecorder()
served := make(chan struct{})
go func() {
defer close(served)
fx.srv.ServeHTTP(rec, req)
}()
<-body.done // the request is dispatched and holds its snapshot
fx.writeConfig(t, fx.configNaming("two")+"# swapped\n")
waitFor(t, "the swap to apply to fresh requests", func() bool {
return fx.status("/one/") == http.StatusNotFound
})
// Only now feed the fetch request: want the tip, no haves, done.
var fetch bytes.Buffer
pktline(&fetch, "want "+fixtureShaMain+"\n")
fetch.WriteString("0000")
pktline(&fetch, "done\n")
var zipped bytes.Buffer
zw := gzip.NewWriter(&zipped)
if _, err := zw.Write(fetch.Bytes()); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if _, err := pw.Write(zipped.Bytes()); err != nil {
t.Fatal(err)
}
pw.Close()
<-served
if rec.Code != http.StatusOK {
t.Fatalf("in-flight fetch failed after swap: status = %d", rec.Code)
}
if !bytes.Contains(rec.Body.Bytes(), []byte("PACK")) {
t.Error("in-flight fetch response carries no pack data")
}
}
func TestInotifyEventBufferParsing(t *testing.T) {
// Build a raw two-event buffer by hand and check name matching and overflow handling.
mkEvent := func(mask uint32, name string) []byte {
nameLen := (len(name)/16 + 1) * 16 // kernel pads names to alignment; emulate
buf := make([]byte, 16+nameLen)
putU32 := func(off int, v uint32) {
buf[off], buf[off+1], buf[off+2], buf[off+3] = byte(v), byte(v>>8), byte(v>>16), byte(v>>24)
}
putU32(4, mask)
putU32(12, uint32(nameLen))
copy(buf[16:], name)
return buf
}
for name, tc := range map[string]struct {
buf []byte
want bool
}{
"match": {mkEvent(0x80, "socle.cfg"), true},
"other-file": {mkEvent(0x80, "unrelated.txt"), false},
"second-event": {append(mkEvent(0x80, "unrelated.txt"), mkEvent(0x80, "socle.cfg")...), true},
"overflow": {mkEvent(0x4000, ""), true},
} {
if got := inotifyEventsMention(tc.buf, "socle.cfg"); got != tc.want {
t.Errorf("%s: got %v, want %v", name, got, tc.want)
}
}
}
watch_test.go · 8.9 KiB · 30aaaf206d