diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 3d386e4..0a43fab 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -7,6 +7,7 @@ import ( "git.golem15.com/golem15/summercms/bonfire" "git.golem15.com/golem15/summercms/internal/build" + "git.golem15.com/golem15/summercms/internal/dev" ) func main() { @@ -27,6 +28,7 @@ func toolCommands() []bonfire.Command { buildCommand(), makePluginCommand(), addPluginCommand(), + devCommand(), } } @@ -72,6 +74,20 @@ func makePluginCommand() bonfire.Command { } } +func devCommand() bonfire.Command { + return bonfire.Command{ + Name: "dev", + Description: "Watch sources and rebuild the app binary", + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + dir, err := os.Getwd() + if err != nil { + return err + } + return dev.Watch(ctx, dir, out) + }, + } +} + func addPluginCommand() bonfire.Command { return bonfire.Command{ Name: "plugin:add", diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index fdc0d89..fedefd8 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) { for _, c := range toolCommands() { names = append(names, c.Name) } - for _, want := range []string{"build", "make:plugin", "plugin:add"} { + for _, want := range []string{"build", "make:plugin", "plugin:add", "dev"} { if !slices.Contains(names, want) { t.Fatalf("missing %s in %v", want, names) } diff --git a/go.mod b/go.mod index 7d2d10f..14f5927 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.0 toolchain go1.27.0 require ( + github.com/fsnotify/fsnotify v1.10.1 github.com/knadh/koanf/parsers/yaml v1.1.1 github.com/knadh/koanf/providers/confmap v1.0.1 github.com/knadh/koanf/providers/env/v2 v2.0.1 @@ -15,7 +16,6 @@ require ( ) require ( - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect diff --git a/go.sum b/go.sum index b822701..5df5ed1 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/internal/dev/watch.go b/internal/dev/watch.go new file mode 100644 index 0000000..253b726 --- /dev/null +++ b/internal/dev/watch.go @@ -0,0 +1,263 @@ +package dev + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + + "git.golem15.com/golem15/summercms/internal/build" +) + +const defaultDebounce = 200 * time.Millisecond + +// Options customizes the watch loop. Tests inject Build and Start. +type Options struct { + Build func(ctx context.Context, appDir string, out io.Writer) error + Start func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) + Debounce time.Duration +} + +// Watch rebuilds the app on source changes and restarts the child binary. +func Watch(ctx context.Context, appDir string, out io.Writer) error { + return watch(ctx, appDir, out, Options{}) +} + +func watch(ctx context.Context, appDir string, out io.Writer, opts Options) error { + if appDir == "" { + return fmt.Errorf("dev: app directory is empty") + } + abs, err := filepath.Abs(appDir) + if err != nil { + return fmt.Errorf("dev: resolve app directory: %w", err) + } + appDir = abs + if out == nil { + out = os.Stdout + } + if opts.Build == nil { + opts.Build = build.App + } + if opts.Start == nil { + opts.Start = startBin + } + if opts.Debounce <= 0 { + opts.Debounce = defaultDebounce + } + + m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml")) + if err != nil { + return err + } + binPath := filepath.Join(appDir, "bin", m.Binary) + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("dev: watcher: %w", err) + } + defer watcher.Close() + if err := addWatches(watcher, appDir); err != nil { + return err + } + + var current *child + defer func() { current.stop() }() + + rebuild := func() { + if ctx.Err() != nil { + return + } + start := time.Now() + if err := opts.Build(ctx, appDir, out); err != nil { + fmt.Fprintf(out, "build error: %v\n", err) + return + } + fmt.Fprintf(out, "rebuild: %s\n", time.Since(start).Round(time.Millisecond)) + if ctx.Err() != nil { + return + } + next, err := startChild(ctx, opts, binPath, out) + if err != nil { + fmt.Fprintf(out, "start error: %v\n", err) + return + } + current.stop() + current = next + } + + rebuild() + + events := make(chan struct{}, 1) + watchErrs := make(chan error, 1) + go func() { + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-watcher.Events: + if !ok { + return + } + if ev.Has(fsnotify.Create) { + if info, err := os.Stat(ev.Name); err == nil && info.IsDir() && !ignoredPath(appDir, ev.Name) { + _ = watcher.Add(ev.Name) + } + } + if !relevant(appDir, ev) { + continue + } + select { + case events <- struct{}{}: + default: + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + if err == nil { + continue + } + select { + case watchErrs <- err: + default: + } + } + } + }() + + var timer *time.Timer + timerC := func() <-chan time.Time { + if timer == nil { + return nil + } + return timer.C + } + + for { + select { + case <-ctx.Done(): + current.stop() + current = nil + return ctx.Err() + case err := <-watchErrs: + fmt.Fprintf(out, "watch error: %v\n", err) + case <-events: + if timer == nil { + timer = time.NewTimer(opts.Debounce) + } else { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(opts.Debounce) + } + case <-timerC(): + timer = nil + rebuild() + } + } +} + +type child struct { + cmd *exec.Cmd + wait <-chan error +} + +func startChild(ctx context.Context, opts Options, binPath string, out io.Writer) (*child, error) { + cmd, err := opts.Start(ctx, binPath, out, out) + if err != nil { + return nil, err + } + if cmd == nil || cmd.Process == nil { + return nil, fmt.Errorf("dev: start returned no process") + } + ch := make(chan error, 1) + go func() { ch <- cmd.Wait() }() + return &child{cmd: cmd, wait: ch}, nil +} + +func (c *child) stop() { + if c == nil || c.cmd == nil || c.cmd.Process == nil { + return + } + _ = c.cmd.Process.Signal(syscall.SIGTERM) + select { + case <-c.wait: + case <-time.After(2 * time.Second): + _ = c.cmd.Process.Kill() + <-c.wait + } +} + +func startBin(_ context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) { + cmd := exec.Command(binPath) + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Stdin = os.Stdin + if err := cmd.Start(); err != nil { + return nil, err + } + return cmd, nil +} + +func addWatches(watcher *fsnotify.Watcher, root string) error { + return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + if ignoredPath(root, path) && path != root { + return filepath.SkipDir + } + return watcher.Add(path) + }) +} + +func relevant(appDir string, ev fsnotify.Event) bool { + if ev.Op == fsnotify.Chmod { + return false + } + if ignoredPath(appDir, ev.Name) { + return false + } + return watchedName(filepath.Base(ev.Name)) +} + +func watchedName(base string) bool { + switch base { + case "go.mod", "go.work", ".env", "summer.yaml": + return true + } + switch filepath.Ext(base) { + case ".go", ".yaml", ".yml": + return true + } + return false +} + +func ignoredPath(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return true + } + if rel == "main.go" || rel == "plugins.gen.go" { + return true + } + for _, part := range strings.Split(rel, string(os.PathSeparator)) { + if part == ".git" || part == "bin" || part == "tmp" { + return true + } + } + return false +} diff --git a/internal/dev/watch_test.go b/internal/dev/watch_test.go new file mode 100644 index 0000000..44ab3d7 --- /dev/null +++ b/internal/dev/watch_test.go @@ -0,0 +1,185 @@ +package dev + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" +) + +func TestWatchRebuildsOnSourceEdit(t *testing.T) { + dir := t.TempDir() + writeWatchApp(t, dir) + + var out bytes.Buffer + var builds atomic.Int32 + var starts atomic.Int32 + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + opts := Options{ + Debounce: 40 * time.Millisecond, + Build: func(ctx context.Context, appDir string, w io.Writer) error { + builds.Add(1) + fmt.Fprintln(w, "built hello in 1ms") + return nil + }, + Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) { + starts.Add(1) + cmd := exec.Command("sleep", "30") + if err := cmd.Start(); err != nil { + return nil, err + } + return cmd, nil + }, + } + + done := make(chan error, 1) + go func() { done <- watch(ctx, dir, &out, opts) }() + + waitUntil(t, "initial start", func() bool { return starts.Load() == 1 }) + if builds.Load() != 1 { + t.Fatalf("initial builds = %d", builds.Load()) + } + + if err := os.WriteFile(filepath.Join(dir, "plugin.go"), []byte("package p\n// edited\n"), 0o644); err != nil { + t.Fatal(err) + } + waitUntil(t, "restart after edit", func() bool { return starts.Load() == 2 }) + if builds.Load() != 2 { + t.Fatalf("builds after edit = %d", builds.Load()) + } + + if err := os.WriteFile(filepath.Join(dir, "plugins.gen.go"), []byte("package main\n// generated\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n// generated\n"), 0o644); err != nil { + t.Fatal(err) + } + time.Sleep(200 * time.Millisecond) + if starts.Load() != 2 || builds.Load() != 2 { + t.Fatalf("generated files triggered rebuild starts=%d builds=%d", starts.Load(), builds.Load()) + } + + got := out.String() + if strings.Count(got, "rebuild:") != 2 { + t.Fatalf("want 2 rebuild latency lines, got %q", got) + } + + cancel() + select { + case err := <-done: + if err != nil && err != context.Canceled { + t.Fatalf("watch exit: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("watch did not exit on cancel") + } +} + +func TestWatchKeepsChildOnBuildFailure(t *testing.T) { + dir := t.TempDir() + writeWatchApp(t, dir) + + var out bytes.Buffer + var builds atomic.Int32 + var starts atomic.Int32 + var pid atomic.Int32 + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + opts := Options{ + Debounce: 40 * time.Millisecond, + Build: func(ctx context.Context, appDir string, w io.Writer) error { + n := builds.Add(1) + if n >= 2 { + return fmt.Errorf("compile failed") + } + return nil + }, + Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) { + starts.Add(1) + cmd := exec.Command("sleep", "30") + if err := cmd.Start(); err != nil { + return nil, err + } + pid.Store(int32(cmd.Process.Pid)) + return cmd, nil + }, + } + + done := make(chan error, 1) + go func() { done <- watch(ctx, dir, &out, opts) }() + waitUntil(t, "initial start", func() bool { return starts.Load() == 1 }) + + if err := os.WriteFile(filepath.Join(dir, "plugin.go"), []byte("package p\n// bad\n"), 0o644); err != nil { + t.Fatal(err) + } + waitUntil(t, "failed rebuild", func() bool { return builds.Load() >= 2 }) + time.Sleep(80 * time.Millisecond) + if starts.Load() != 1 { + t.Fatalf("build failure restarted child: starts=%d", starts.Load()) + } + if !strings.Contains(out.String(), "compile failed") { + t.Fatalf("missing build error: %s", out.String()) + } + proc, err := os.FindProcess(int(pid.Load())) + if err != nil { + t.Fatal(err) + } + if err := proc.Signal(syscall.Signal(0)); err != nil { + t.Fatalf("child was reaped after failed build: %v", err) + } + + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("watch did not exit") + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := proc.Signal(syscall.Signal(0)); err != nil { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("child still running after cancel") +} + +func writeWatchApp(t *testing.T, dir string) { + t.Helper() + write(t, filepath.Join(dir, "summer.yaml"), "module: example.com/app\nbinary: hello\nplugins:\n - id: a.b\n module: example.com/app/plugins/demo\n") + write(t, filepath.Join(dir, "plugin.go"), "package p\n") + write(t, filepath.Join(dir, "main.go"), "package main\n") + write(t, filepath.Join(dir, "plugins.gen.go"), "package main\n") +} + +func write(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func waitUntil(t *testing.T, name string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if ok() { + return + } + time.Sleep(15 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", name) +}