package main import ( "bufio" "context" "flag" "fmt" "io" "io/fs" "os" "os/exec" "os/signal" "path/filepath" "strings" "syscall" "time" "atlas9.dev/c/core/site" "atlas9.dev/c/core/tracelog" "github.com/fsnotify/fsnotify" ) func cmdRun(args []string) { fs := flag.NewFlagSet("atlas9 run", flag.ExitOnError) logPath := fs.String("log", "data/server.log", "file to write raw JSON logs to (truncated each run)") watch := fs.Bool("watch", true, "watch sources and rebuild/regenerate/relaunch on change") devPort := fs.Int("devport", 8099, "port for the dev tools UI (0 to disable)") traces := fs.Bool("traces", false, "render request traces in the terminal") fs.Parse(args) // The build/seed phases and the server all emit JSON logs; the collector // pretty-prints them so every phase renders the same way. col := &tracelog.Collector{Out: os.Stdout, PassThrough: true, Color: tracelog.IsTerminal(os.Stdout)} if !*traces { col.Filter = func(string, []tracelog.Entry) bool { return false } } if _, err := os.Stat("tools"); err == nil { banner(col, "atlas9: running tools/") if err := runStep("tools", col); err != nil { errBanner(col, "atlas9: tools/ failed: %v", err) os.Exit(1) } } if _, err := os.Stat("dev_data"); err == nil { banner(col, "atlas9: running dev_data/") if err := runStep("dev_data", col); err != nil { errBanner(col, "atlas9: dev_data/ failed: %v", err) os.Exit(1) } } if _, err := os.Stat("site"); err == nil { banner(col, "atlas9: building site/") if err := site.Build("site", "site/dist"); err != nil { errBanner(col, "atlas9: building site/ failed: %v", err) os.Exit(1) } // Point the app at the built site so `atlas9 run` serves it with no // extra config. Production doesn't use the runner, so this stays unset // there and the app never serves the site. os.Setenv("ATLAS9_SITE_DIR", "site/dist") } if err := run(*logPath, *watch, *devPort, col, fs.Args()); err != nil { errBanner(col, "atlas9: %v", err) os.Exit(1) } } func run(logPath string, watch bool, devPort int, col *tracelog.Collector, args []string) error { if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { return err } // Truncate on each run so the log file doesn't grow unbounded across runs. // The dev tools panel is fed live during the run, and `atlas9 traces` tails // the file as it's written, so cross-run history isn't needed here. logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { return err } defer logFile.Close() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var ds *devServer if devPort > 0 { ds = newDevServer() addr := fmt.Sprintf("localhost:%d", devPort) banner(col, "atlas9: dev tools at http://%s", addr) go func() { if err := ds.Start(addr); err != nil { errBanner(col, "atlas9: dev server: %v", err) } }() } sweeper := time.NewTicker(time.Second) defer sweeper.Stop() go func() { for range sweeper.C { col.Sweep(time.Now()) if ds != nil { ds.Sweep(time.Now()) } } }() if watch && len(args) == 0 { return runWatch(ctx, logFile, col, ds) } if len(args) == 0 { args = []string{"go", "run", "."} } banner(col, "atlas9: running %v, logging to %s", args, logPath) return runOnce(ctx, args, logFile, col, ds) } // stage is a bitmask of the pipeline steps a set of file changes requires. type stage uint8 const ( stageApp stage = 1 << iota // rebuild the binary and relaunch the app stageRegen // re-run tools/ (API + client codegen) stageSite // rebuild the marketing site ) // runWatch keeps the app running across edits. It watches the source tree and, // on each change, runs the affected pipeline steps — codegen, site build, // rebuild — then relaunches the app. Two properties make restarts invisible: // the app is only swapped after a successful build (a broken build leaves the // last good process serving), and an app that exits on its own is reported but // doesn't take the runner down, so the next edit brings it back. func runWatch(ctx context.Context, logFile *os.File, col *tracelog.Collector, ds *devServer) error { binPath := filepath.Join(os.TempDir(), "atlas9-dev") changes := watchFiles(ctx, ".") // proc is the running app, or nil when no good build exists yet. var proc *appProc // rebuild compiles the app and, on success, replaces the running process. // On failure the current process keeps serving. rebuild := func() { banner(col, "atlas9: building...") if err := buildBinary(ctx, binPath); err != nil { if ctx.Err() != nil { return } errBanner(col, "atlas9: build failed") return } if proc != nil { proc.stop() } p, err := startApp(ctx, binPath, logFile, col, ds) if err != nil { errBanner(col, "atlas9: starting: %v", err) proc = nil return } proc = p } rebuild() for { select { case <-ctx.Done(): if proc != nil { proc.stop() } return nil case <-proc.exited(): // The app exited on its own (a crash, or a fatal boot error). Report // it and keep watching so the next edit can bring it back rather than // tearing down the whole runner. if err := proc.waitErr; err != nil { errBanner(col, "atlas9: app exited: %v", err) } else { banner(col, "atlas9: app exited") } proc = nil case dirty := <-changes: if dirty&stageRegen != 0 { banner(col, "atlas9: regenerating...") if err := runStep("tools", col); err != nil { errBanner(col, "atlas9: tools/ failed: %v", err) } } if dirty&stageSite != 0 { banner(col, "atlas9: building site/") if err := site.Build("site", "site/dist"); err != nil { errBanner(col, "atlas9: building site/ failed: %v", err) } } // Regen rewrites compiled-in code, so it implies a rebuild too. if dirty&(stageApp|stageRegen) != 0 { banner(col, "atlas9: change detected, rebuilding...") rebuild() } } } } // appProc is a running app subprocess. done is closed once the process has // exited and its logs have drained; waitErr then holds the exit error, if any. type appProc struct { cmd *exec.Cmd done chan struct{} waitErr error } // startApp launches the built binary, streaming its logs to the collector and // dev server. It returns as soon as the process is started; watch proc.exited() // to learn when it stops. func startApp(ctx context.Context, binPath string, logFile *os.File, col *tracelog.Collector, ds *devServer) (*appProc, error) { pr, pw, err := os.Pipe() if err != nil { return nil, err } cmd := exec.CommandContext(ctx, binPath) cmd.Stdout = pw cmd.Stderr = pw cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } banner(col, "atlas9: starting") if err := cmd.Start(); err != nil { pw.Close() pr.Close() return nil, err } pw.Close() p := &appProc{cmd: cmd, done: make(chan struct{})} scanDone := make(chan struct{}) go func() { defer close(scanDone) streamLogs(pr, logFile, col, ds) }() go func() { err := cmd.Wait() <-scanDone // let the log stream drain before signalling exit col.Flush() p.waitErr = err close(p.done) }() return p, nil } // exited reports the process's exit. A nil appProc returns a nil channel, which // blocks forever, so the caller can select on it whether or not one is running. func (p *appProc) exited() <-chan struct{} { if p == nil { return nil } return p.done } // stop interrupts the process and waits for it to exit and its logs to drain. func (p *appProc) stop() { p.cmd.Process.Signal(os.Interrupt) <-p.done } // runOnce runs a custom command forwarding its logs to the log file and trace // collector. Used when a command is given after "--". func runOnce(ctx context.Context, args []string, logFile *os.File, col *tracelog.Collector, ds *devServer) error { pr, pw, err := os.Pipe() if err != nil { return err } cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Env = goEnv() cmd.Stdout = pw cmd.Stderr = pw cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } if err := cmd.Start(); err != nil { return err } pw.Close() streamLogs(pr, logFile, col, ds) col.Flush() return cmd.Wait() } // runStep runs a build/seed subprocess (tools/, dev_data/), routing its JSON // logs through the collector so they render like the server's rather than as // raw slog default output. func runStep(name string, col *tracelog.Collector) error { pr, pw, err := os.Pipe() if err != nil { return err } cmd := exec.Command("go", "run", "./"+name+"/") cmd.Env = goEnv() cmd.Stdout = pw cmd.Stderr = pw if err := cmd.Start(); err != nil { pw.Close() pr.Close() return err } pw.Close() streamLogs(pr, nil, col, nil) col.Flush() return cmd.Wait() } // streamLogs scans a subprocess's output line by line: each line is appended to // logFile (when set), and JSON log records are routed to the collector (and dev // server) for pretty-printing. Lines that aren't log records print as-is. func streamLogs(r io.Reader, logFile *os.File, col *tracelog.Collector, ds *devServer) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) for scanner.Scan() { if logFile != nil { logFile.Write(append(scanner.Bytes(), '\n')) } id, entry, ok := tracelog.ParseLine(scanner.Bytes()) if !ok { fmt.Println(scanner.Text()) continue } if entry.Msg == "task queue metrics" { if ds != nil { ds.AddMetrics(scanner.Bytes()) } continue } col.Add(id, entry) if ds != nil { ds.Add(id, entry) } } if err := scanner.Err(); err != nil { errBanner(col, "atlas9: reading logs: %v", err) } } func buildBinary(ctx context.Context, outPath string) error { cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, ".") cmd.Env = goEnv() cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // banner renders a runner status line through the collector so it appears in // the same timestamped format as the app and tool logs. func banner(col *tracelog.Collector, format string, args ...any) { col.Add("", tracelog.Entry{Time: time.Now(), Level: "INFO", Msg: fmt.Sprintf(format, args...)}) } // errBanner is banner at ERROR level, for the runner's own failures. func errBanner(col *tracelog.Collector, format string, args ...any) { col.Add("", tracelog.Entry{Time: time.Now(), Level: "ERROR", Msg: fmt.Sprintf(format, args...)}) } // goEnv is the environment for the go subprocesses the runner spawns. It forces // GOEXPERIMENT=jsonv2 so the app (which uses encoding/json/v2) builds without a // machine-global setting. func goEnv() []string { return append(os.Environ(), "GOEXPERIMENT=jsonv2") } // watchFiles watches the source tree with fsnotify and sends the set of // pipeline stages that changed files require. Events are debounced: a burst of // writes (an editor save, a branch checkout) collapses into one send once the // tree goes quiet, so a large change heals in a single pass. fsnotify replaces // an earlier modtime poll, which could miss changes across a machine sleep. func watchFiles(ctx context.Context, dir string) <-chan stage { changes := make(chan stage, 1) w, err := fsnotify.NewWatcher() if err != nil { fmt.Fprintln(os.Stderr, "atlas9: file watching unavailable:", err) return changes } watchTree(w, dir) go func() { defer w.Close() // quiet is how long the tree must be still before a batch fires. The // timer stays stopped while idle and is re-armed by each event. const quiet = 150 * time.Millisecond var pending stage timer := time.NewTimer(quiet) timer.Stop() for { select { case <-ctx.Done(): return case e, ok := <-w.Events: if !ok { return } // Newly created directories (a new package, a branch checkout) // need their own watches; fsnotify is not recursive. if e.Op&fsnotify.Create != 0 { if info, err := os.Stat(e.Name); err == nil && info.IsDir() { watchTree(w, e.Name) } } if s := classify(e.Name); s != 0 { pending |= s timer.Reset(quiet) } case <-timer.C: // Deliver without blocking the event loop: if the consumer is // mid-rebuild, keep draining events (so nothing is dropped) and // retry after another quiet window. select { case changes <- pending: pending = 0 default: timer.Reset(quiet) } case err, ok := <-w.Errors: if !ok { return } fmt.Fprintln(os.Stderr, "atlas9: file watch error:", err) } } }() return changes } // watchTree adds a watch on root and every directory under it, skipping the // dirs skipDir excludes. Adding an already-watched directory is a no-op, so it // is safe to call again for subtrees created after startup. func watchTree(w *fsnotify.Watcher, root string) { filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil || !d.IsDir() { return nil } if skipDir(d.Name()) { return fs.SkipDir } w.Add(path) return nil }) } // classify maps a file path to the pipeline stages its change requires, or 0 // for files that don't affect a running dev server. Pipeline outputs // (generated code, the built site, the copied client) map to 0 so regenerating // them never re-triggers the pipeline that produced them. func classify(path string) stage { p := filepath.ToSlash(path) // Test files aren't compiled into the app, so they never affect what's // running. if strings.HasSuffix(p, "_test.go") { return 0 } switch { // Generated outputs: never triggers, so the pipeline can't feed itself. case strings.HasPrefix(p, "api/generated/"), strings.HasPrefix(p, "site/dist/"), p == "frontend/client.js": return 0 // Directory conventions. case strings.HasPrefix(p, "site/"): return stageSite case strings.HasPrefix(p, "api/"), strings.HasPrefix(p, "tools/"): return stageRegen case strings.HasPrefix(p, "frontend/"): // served from disk, already live return 0 case strings.HasPrefix(p, "dev_data/"): // seeding is startup-only return 0 } // Everything else: app code, migrations, and boot config force a rebuild. switch { case strings.HasSuffix(p, ".go"), strings.HasSuffix(p, ".sql"), p == "config.toml", p == "go.mod", p == "go.sum": return stageApp } return 0 } func skipDir(name string) bool { switch name { case "vendor", "node_modules", "data": // data holds the dev DB and server.log, which churn constantly and map // to no pipeline stage; watching it is pure noise. return true } return strings.HasPrefix(name, ".") }