package xfer
import (
"errors"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
"craftdesign.group/quarry/internal/children"
)
// The store child's stderr must be an *os.File, and the forge must own
// the reader.
//
// This is a structural assertion on purpose. The forge log gained the
// child's diagnostics by assigning a plain io.Writer to cmd.Stderr.
// os/exec connects an *os.File to the child directly, but copies
// anything else on a goroutine that Wait() blocks on, in
// awaitGoroutines, until every write end of that pipe is closed.
// Process.Kill() closes the direct child's; a grandchild holding the
// inherited descriptor keeps its own, and Wait() never returns.
//
// That is not a slow test, it is a stopped forge. Close() and
// Unregister() hold f.mu across the wait, ensure() and ServeHTTP take
// the same mutex, so one removed repository wedges sync for every
// repository. It hung two CI runs at the ten-minute timeout, and the
// goroutine dump named awaitGoroutines under Fronter.Close.
//
// Asserted structurally rather than by racing a grandchild: a
// synthetic child that leaves one holding stderr did NOT reproduce the
// stall reliably here, and a guard that passes while the bug is
// present is worse than none. The type of Stderr is the invariant that
// actually prevents it, it is what regressed, and it cannot pass by
// luck.
func TestTheChildStderrIsAFileNotACopiedWriter(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
dir := t.TempDir()
zrun(t, dir, "init")
f := New()
defer f.Close()
f.Register("alpha", dir)
b, err := f.ensure("alpha")
if err != nil {
t.Fatal(err)
}
if _, ok := b.cmd.Stderr.(*os.File); !ok {
t.Fatalf("FAIL: cmd.Stderr is %T, not *os.File — os/exec will copy it on a "+
"goroutine and Wait() will block in awaitGoroutines until every write "+
"end is closed, which a grandchild never does", b.cmd.Stderr)
}
if b.errR == nil {
t.Error("FAIL: the forge does not hold the read end, so stop() cannot end the reading")
}
}
// And stopping releases both, promptly.
func TestStoppingTheChildReleasesItAndItsReader(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
dir := t.TempDir()
zrun(t, dir, "init")
f := New()
defer f.Close()
f.Register("alpha", dir)
b, err := f.ensure("alpha")
if err != nil {
t.Fatal(err)
}
// The store is real, so the child has something to say and the
// reader has something to read before it is closed under it.
_ = filepath.Join(dir, ".zit")
done := make(chan struct{})
go func() { b.stop(); close(done) }()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("FAIL: stop() did not return — the child's stderr is being waited on")
}
if b.cmd != nil {
t.Error("FAIL: stop() left the command in place")
}
if b.errR != nil {
t.Error("FAIL: stop() left the stderr reader open")
}
}
// stop returns only once the serve child has exited and been reaped,
// and the child is one of the process's tracked children while it runs,
// so a stopping forge waits for it and logs its end instead of leaving
// it for systemd to SIGKILL.
func TestStoppingTheChildWaitsForItToExit(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
dir := t.TempDir()
zrun(t, dir, "init")
f := New()
defer f.Close()
f.Register("alpha", dir)
before := children.Default.Live()
b, err := f.ensure("alpha")
if err != nil {
t.Fatal(err)
}
if children.Default.Live() != before+1 {
t.Fatal("FAIL: the serve child is not tracked, so a stop cannot wait for it")
}
t.Log("ok: the serve child is tracked")
pid := b.cmd.Process.Pid
f.Unregister("alpha")
if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
t.Fatalf("FAIL: pid %d still exists after Unregister returned (kill 0: %v)", pid, err)
}
t.Log("ok: the child is gone when Unregister returns")
if children.Default.Live() != before {
t.Fatal("FAIL: the stopped serve child is still tracked")
}
t.Log("ok: and no longer tracked")
}