4 Commits

Author SHA1 Message Date
Zoe
f7ac354bcf fix config typos and misleading errors 2025-12-06 23:27:54 -06:00
Zoe
dcd8d810f0 More robust script handling and code cleanup
In this commit, I have made the script handling dramatically more
robust, and the code has been cleaned up a fair amount. We no longer
have to manually set is restarting or anything like that, and we dont
rely on a fragile exit code system.
2025-10-07 16:15:05 +00:00
Zoe
4a70260ea5 Better debugging, and several bug fixes
This commit brings a greater experience for developers working on ZQDGR,
and also fixes a number of bugs. The following bugs have been fixed:
- Scripts that died in watch mode did not restart once changes were made
- Created and deleted files did not cause a reload
- script.Stop is now used in every place where we kill the process, this
  ensures that the process is *eventually* actually killed
2025-10-06 15:54:31 -05:00
Zoe
a62bfdb01d Fix restart after program has died 2025-10-06 14:43:33 +00:00
5 changed files with 217 additions and 169 deletions

View File

@@ -5,6 +5,13 @@ how you would use npm. ZQDGR lets you watch files and rebuild your project as yo
optional websocket server that will notify listeners that a rebuild has occurred, this is very useful for live reloading
when doing web development with Go.
**Status: Alpha**
This project is not recommend for production use, you are allowed to, and I try
my best to keep it as stable as possible, but features and APIs will change
in nearly every release without backwards compatibility, upgrade carefilly, and
make sure to depend on a specific version of ZQDGR.
## Install
```bash

View File

@@ -9,5 +9,5 @@
"dev": "go run main.go"
},
"pattern": "**/*.go",
"excluded_dirs": []
"excluded_globs": []
}

327
main.go
View File

@@ -8,6 +8,7 @@ import (
"flag"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/exec"
@@ -15,6 +16,7 @@ import (
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
@@ -38,24 +40,20 @@ type Config struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"repository"`
Scripts map[string]string `json:"scripts"`
Pattern string `json:"pattern"`
// Deprecated: use excludedGlobs instead
ExcludedDirs []string `json:"excluded_dirs"`
ExcludedGlobs []string `json:"excluded_files"`
ShutdownSignal string `json:"shutdown_signal"`
ShutdownTimeout int `json:"shutdown_timeout"`
Scripts map[string]string `json:"scripts"`
Pattern string `json:"pattern"`
ExcludedGlobs []string `json:"excluded_globs"`
ShutdownSignal string `json:"shutdown_signal"`
ShutdownTimeout int `json:"shutdown_timeout"`
}
type Script struct {
zqdgr *ZQDGR
command *exec.Cmd
mutex sync.Mutex
scriptName string
isRestarting bool
wg sync.WaitGroup
// the exit code of the script, only set after the script has exited
exitCode int
zqdgr *ZQDGR
command *exec.Cmd
mutex sync.Mutex
scriptName string
// notified with the exit code of the script when it exits
exitCode chan int
}
type ZQDGR struct {
@@ -132,10 +130,10 @@ func (zqdgr *ZQDGR) NewScript(scriptName string, args ...string) *Script {
}
return &Script{
zqdgr: zqdgr,
command: command,
scriptName: scriptName,
isRestarting: false,
zqdgr: zqdgr,
command: command,
scriptName: scriptName,
exitCode: make(chan int),
}
}
@@ -143,69 +141,66 @@ func (s *Script) Start() error {
s.mutex.Lock()
defer s.mutex.Unlock()
s.wg.Add(1)
err := s.command.Start()
if err != nil {
s.wg.Done()
return err
}
go func() {
err := s.command.Wait()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
s.exitCode = exitError.ExitCode()
} else {
// Other errors (e.g., process not found, permission denied)
if !s.isRestarting {
log.Printf("Error waiting for script %s: %v", s.scriptName, err)
s.exitCode = 1
}
}
}
processState, err := s.command.Process.Wait()
slog.Debug("Script exited", "script", s.scriptName, "error", err)
if !s.isRestarting {
s.wg.Done()
}
s.exitCode <- processState.ExitCode()
}()
return err
}
func (s *Script) Stop(lock bool) error {
if lock {
s.mutex.Lock()
defer s.mutex.Unlock()
}
s.isRestarting = true
if s.command.Process != nil {
var signal syscall.Signal
switch s.zqdgr.Config.ShutdownSignal {
case "SIGINT":
signal = syscall.SIGINT
case "SIGTERM":
signal = syscall.SIGTERM
case "SIGQUIT":
signal = syscall.SIGQUIT
default:
signal = syscall.SIGKILL
// it is the caller's responsibility to lock the mutex before calling this function
func (s *Script) Stop() error {
slog.Debug("Making sure process is still alive")
if runtime.GOOS == "windows" {
if _, err := os.FindProcess(s.command.Process.Pid); err != nil {
// process is already dead
return nil
}
if err := syscall.Kill(-s.command.Process.Pid, signal); err != nil {
log.Printf("error killing previous process: %v", err)
} else {
process, err := os.FindProcess(s.command.Process.Pid)
if err != nil {
return err
}
// Sending signal 0 checks for existence and permissions
err = process.Signal(syscall.Signal(0))
if err != nil {
return nil
}
}
slog.Debug("Process is still alive, sending signal")
dead := make(chan bool)
go func() {
s.command.Wait()
dead <- true
}()
var signal syscall.Signal
switch s.zqdgr.Config.ShutdownSignal {
case "SIGINT":
signal = syscall.SIGINT
case "SIGTERM":
signal = syscall.SIGTERM
case "SIGQUIT":
signal = syscall.SIGQUIT
default:
signal = syscall.SIGKILL
}
if err := syscall.Kill(-s.command.Process.Pid, signal); err != nil {
log.Printf("error killing previous process: %v", err)
return err
}
shutdownTimeout := s.zqdgr.Config.ShutdownTimeout
if shutdownTimeout == 0 {
shutdownTimeout = 1
@@ -226,9 +221,11 @@ func (s *Script) Stop(lock bool) error {
}
func (s *Script) Restart() error {
slog.Debug("Restarting script", "script", s.scriptName)
s.mutex.Lock()
err := s.Stop(false)
err := s.Stop()
if err != nil {
s.mutex.Unlock()
return err
@@ -242,8 +239,6 @@ func (s *Script) Restart() error {
return nil
}
s.isRestarting = false
s.mutex.Unlock()
err = s.Start()
@@ -265,10 +260,6 @@ func (s *Script) Restart() error {
return err
}
func (s *Script) Wait() {
s.wg.Wait()
}
func (wsServer *WSServer) handleWs(w http.ResponseWriter, r *http.Request) {
conn, err := wsServer.upgrader.Upgrade(w, r, nil)
if err != nil {
@@ -310,27 +301,79 @@ func (zqdgr *ZQDGR) loadConfig() error {
}
}
if zqdgr.Config.ExcludedDirs != nil {
fmt.Printf("WARNING: the 'excluded_dirs' key is deprecated, please use 'excluded_globs' instead\n")
zqdgr.Config.ExcludedGlobs = append(zqdgr.Config.ExcludedGlobs, zqdgr.Config.ExcludedDirs...)
}
return nil
}
func validatePattern(pattern string) ([]string, error) {
var paternArray []string
var currentPattern string
inMatch := false
// iterate over every letter in the pattern
for _, p := range pattern {
if string(p) == "{" {
if inMatch {
return nil, fmt.Errorf("unexpected { in pattern")
}
inMatch = true
}
if string(p) == "}" {
if !inMatch {
return nil, fmt.Errorf("enexpected } in pattern")
}
inMatch = false
}
if string(p) == "," && !inMatch {
paternArray = append(paternArray, currentPattern)
currentPattern = ""
continue
}
currentPattern += string(p)
}
if inMatch {
return nil, fmt.Errorf("unmatched } in pattern")
}
if currentPattern != "" {
paternArray = append(paternArray, currentPattern)
}
return paternArray, nil
}
func main() {
// var err error
var debugMode bool
noWs := flag.Bool("no-ws", false, "Disable WebSocket server")
configDir := flag.String("config", ".", "Path to the config directory")
disableReloadConfig := flag.Bool("no-reload-config", false, "Do not restart ZQDGR on config file change")
flag.StringVar(configDir, "C", *configDir, "Path to the config directory")
disableReloadConfig := flag.Bool("no-reload-config", false, "Do not restart ZQDGR on config file change")
flag.Parse()
// if ParseBool returns an error, ZQDGR_DEBUG is not a bool
debugMode, _ = strconv.ParseBool(os.Getenv("ZQDGR_DEBUG"))
if debugMode {
slog.SetLogLoggerLevel(slog.LevelDebug)
}
originalArgs := os.Args
os.Args = flag.Args()
zqdgr := NewZQDGR(*noWs, *configDir)
expandedConfigDir, err := filepath.Abs(*configDir)
if err != nil {
log.Fatal(err)
}
slog.Debug("noWS", "noWs", *noWs, "configDir", *configDir, "expandedConfigDir", expandedConfigDir, "disableReloadConfig", *disableReloadConfig)
zqdgr := NewZQDGR(*noWs, expandedConfigDir)
if zqdgr == nil {
return
}
@@ -338,10 +381,13 @@ func main() {
var command string
var commandArgs []string
// command name trimmed by flags.Args()
// os.Args ~= [script, --, arguments]
for i, arg := range os.Args {
if arg == "--" {
if i+2 < len(os.Args) {
commandArgs = os.Args[i+2:]
slog.Debug("Found double-dash", "i", i, "len(os.Args)", len(os.Args), "os.Args", os.Args)
if len(os.Args)-i > 1 {
commandArgs = os.Args[i+1:]
}
break
}
@@ -354,6 +400,8 @@ func main() {
commandArgs = append(commandArgs, arg)
}
slog.Debug("Collected", "command", command, "commandArgs", commandArgs)
watchMode := false
var scriptName string
switch command {
@@ -496,13 +544,7 @@ func main() {
log.Fatal("please specify a script to run")
}
watchMode = true
for i := range commandArgs {
if strings.HasPrefix(commandArgs[i], "-") {
continue
}
scriptName = commandArgs[i]
}
scriptName = commandArgs[0]
default:
scriptName = command
}
@@ -520,19 +562,9 @@ func main() {
log.Println("Received signal, exiting...")
if script.command != nil {
var signal syscall.Signal
switch zqdgr.Config.ShutdownSignal {
case "SIGINT":
signal = syscall.SIGINT
case "SIGTERM":
signal = syscall.SIGTERM
case "SIGQUIT":
signal = syscall.SIGQUIT
default:
signal = syscall.SIGKILL
}
syscall.Kill(-script.command.Process.Pid, signal)
script.mutex.Lock()
script.Stop()
script.mutex.Unlock()
}
os.Exit(0)
@@ -556,48 +588,21 @@ func main() {
}
// make sure the pattern is valid
var paternArray []string
var currentPattern string
inMatch := false
// iterate over every letter in the pattern
for _, p := range zqdgr.Config.Pattern {
if string(p) == "{" {
if inMatch {
log.Fatal("unmatched { in pattern")
}
inMatch = true
}
if string(p) == "}" {
if !inMatch {
log.Fatal("unmatched } in pattern")
}
inMatch = false
}
if string(p) == "," && !inMatch {
paternArray = append(paternArray, currentPattern)
currentPattern = ""
inMatch = false
continue
}
currentPattern += string(p)
patternArray, err := validatePattern(zqdgr.Config.Pattern)
if err != nil {
log.Fatal(err)
}
if inMatch {
log.Fatal("unmatched } in pattern")
}
if currentPattern != "" {
paternArray = append(paternArray, currentPattern)
for _, pattern := range zqdgr.Config.ExcludedGlobs {
_, err := validatePattern(pattern)
if err != nil {
log.Fatal(err)
}
}
watcherConfig := WatcherConfig{
excludedGlobs: globList(zqdgr.Config.ExcludedGlobs),
pattern: paternArray,
pattern: patternArray,
}
watcher, err := NewWatcher(&watcherConfig)
@@ -617,13 +622,12 @@ func main() {
log.Fatal(err)
}
// We use this timer to deduplicate events.
// tailing edge debounce of file system events
var (
// Wait 100ms for new events; each new event resets the timer.
waitFor = 100 * time.Millisecond
// Keep track of the timers, as path → timer.
mu sync.Mutex
mu sync.Mutex
// watched filepath -> timer
timers = make(map[string]*time.Timer)
)
go func() {
@@ -640,25 +644,37 @@ func main() {
if !ok {
timer = time.AfterFunc(waitFor, func() {
if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
if os.Getenv("ZQDGR_DEBUG") != "" {
fmt.Println("File changed:", event.Name)
}
slog.Debug("FSnotify event received", "event", event)
if strings.HasSuffix(event.Name, "zqdgr.config.json") {
if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
slog.Debug("File changed", "file", event.Name)
fullEventPath, err := filepath.Abs(event.Name)
if err != nil {
log.Fatal(err)
}
slog.Debug("expanded event path", "path", fullEventPath)
// check against the fullpath to make sure that the config file that was changed is
// actually the one we are using
if fullEventPath == filepath.Join(zqdgr.WorkingDirectory, "zqdgr.config.json") {
// re-exec the exact same command
if !*disableReloadConfig {
log.Println("zqdgr.config.json has changed, restarting...")
fmt.Println("zqdgr.config.json has changed, restarting...")
executable, err := os.Executable()
if err != nil {
log.Fatal(err)
}
err = script.Stop(true)
script.mutex.Lock()
err = script.Stop()
if err != nil {
log.Fatal(err)
}
script.mutex.Unlock()
err = syscall.Exec(executable, originalArgs, os.Environ())
if err != nil {
log.Fatal(err)
@@ -668,8 +684,16 @@ func main() {
}
}
if directoryShouldBeTracked(&watcherConfig, event.Name) {
watcher.(NotifyWatcher).watcher.Add(event.Name)
if pathShouldBeTracked(&watcherConfig, event.Name) {
if event.Op&fsnotify.Create == fsnotify.Create {
slog.Debug("Adding new file to watcher", "file", event.Name)
watcher.(NotifyWatcher).watcher.Add(event.Name)
}
if event.Op&fsnotify.Remove == fsnotify.Remove {
slog.Debug("Removing file from watcher", "file", event.Name)
watcher.(NotifyWatcher).watcher.Remove(event.Name)
}
}
if pathMatches(&watcherConfig, event.Name) {
@@ -702,8 +726,17 @@ func main() {
}
}
}()
// block until the script exits with a zero (aka, it normally exited on its own)
for {
exitCode := <-script.exitCode
slog.Debug("Script exited", "script", scriptName, "exitCode", exitCode)
if exitCode == 0 {
os.Exit(0)
}
}
}
script.Wait()
os.Exit(script.exitCode)
// block until the script exits
os.Exit(<-script.exitCode)
}

View File

@@ -4,7 +4,7 @@ import (
"errors"
"fmt"
"log"
"os"
"log/slog"
"path/filepath"
"strings"
@@ -34,7 +34,9 @@ func (g *globList) Matches(value string) bool {
func matchesPattern(pattern []string, path string) bool {
for _, p := range pattern {
slog.Debug("checking path against pattern", "pattern", p, "path", path)
if matched, _ := doublestar.Match(p, path); matched {
slog.Debug("path matches pattern", "pattern", p, "path", path)
return true
}
}
@@ -42,17 +44,13 @@ func matchesPattern(pattern []string, path string) bool {
return false
}
func directoryShouldBeTracked(cfg *WatcherConfig, path string) bool {
func pathShouldBeTracked(cfg *WatcherConfig, path string) bool {
base := filepath.Dir(path)
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("checking %s against %s %v\n", path, base, *cfg)
}
slog.Debug("checking file against path", "path", path, "base", base)
if cfg.excludedGlobs.Matches(base) {
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("%s is excluded\n", base)
}
slog.Debug("file is excluded", "path", path)
return false
}
@@ -86,9 +84,7 @@ func (n NotifyWatcher) Close() error {
}
func (n NotifyWatcher) AddFile(path string) error {
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("manually adding file\n")
}
slog.Debug("manually adding file", "file", path)
return n.add(path)
}
@@ -125,24 +121,36 @@ func NewWatcher(cfg *WatcherConfig) (FileWatcher, error) {
func addFiles(fw FileWatcher) error {
cfg := fw.getConfig()
for _, pattern := range cfg.pattern {
if os.Getenv("ZQDGR_DEBUG") != "" {
fmt.Printf("processing glob %s\n", pattern)
}
slog.Debug("adding pattern", "pattern", pattern)
matches, err := doublestar.Glob(pattern)
if err != nil {
log.Fatalf("Bad pattern \"%s\": %s", pattern, err.Error())
}
trackedDirs := make(map[string]bool)
for _, match := range matches {
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("checking %s\n", match)
base := filepath.Dir(match)
// this allows us to track file creations and deletions
if !trackedDirs[base] {
if cfg.excludedGlobs.Matches(base) {
slog.Debug("directory is excluded", "file", match)
continue
}
trackedDirs[base] = true
slog.Debug("adding directory", "dir", base)
if err := fw.add(base); err != nil {
return fmt.Errorf("FileWatcher.Add(): %v", err)
}
}
if directoryShouldBeTracked(cfg, match) {
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("%s is not excluded\n", match)
}
slog.Debug("adding file", "file", match)
if pathShouldBeTracked(cfg, match) {
slog.Debug("path should be tracked", "file", match)
if err := fw.add(match); err != nil {
return fmt.Errorf("FileWatcher.Add(): %v", err)

View File

@@ -1,6 +1,6 @@
{
"name": "zqdgr",
"version": "0.0.4",
"version": "0.0.6.1",
"description": "zqdgr is a quick and dirty Golang runner",
"author": "juls0730",
"license": "BSL-1.0",