socle

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 · 6b3477bda1