2 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
4 changed files with 175 additions and 148 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 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. 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 ## Install
```bash ```bash

View File

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

262
main.go
View File

@@ -42,9 +42,7 @@ type Config struct {
} `json:"repository"` } `json:"repository"`
Scripts map[string]string `json:"scripts"` Scripts map[string]string `json:"scripts"`
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
// Deprecated: use excludedGlobs instead ExcludedGlobs []string `json:"excluded_globs"`
ExcludedDirs []string `json:"excluded_dirs"`
ExcludedGlobs []string `json:"excluded_files"`
ShutdownSignal string `json:"shutdown_signal"` ShutdownSignal string `json:"shutdown_signal"`
ShutdownTimeout int `json:"shutdown_timeout"` ShutdownTimeout int `json:"shutdown_timeout"`
} }
@@ -54,10 +52,8 @@ type Script struct {
command *exec.Cmd command *exec.Cmd
mutex sync.Mutex mutex sync.Mutex
scriptName string scriptName string
isRestarting bool // notified with the exit code of the script when it exits
wg sync.WaitGroup exitCode chan int
// the exit code of the script, only set after the script has exited
exitCode int
} }
type ZQDGR struct { type ZQDGR struct {
@@ -137,7 +133,7 @@ func (zqdgr *ZQDGR) NewScript(scriptName string, args ...string) *Script {
zqdgr: zqdgr, zqdgr: zqdgr,
command: command, command: command,
scriptName: scriptName, scriptName: scriptName,
isRestarting: false, exitCode: make(chan int),
} }
} }
@@ -145,45 +141,49 @@ func (s *Script) Start() error {
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()
s.wg.Add(1)
err := s.command.Start() err := s.command.Start()
if err != nil { if err != nil {
s.wg.Done()
return err return err
} }
go func() { go func() {
err := s.command.Wait() processState, err := s.command.Process.Wait()
if err != nil { slog.Debug("Script exited", "script", s.scriptName, "error", err)
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
}
}
}
if !s.isRestarting { s.exitCode <- processState.ExitCode()
s.wg.Done()
}
}() }()
return err return err
} }
func (s *Script) Stop(lock bool) error { // it is the caller's responsibility to lock the mutex before calling this function
if lock { func (s *Script) Stop() error {
s.mutex.Lock() slog.Debug("Making sure process is still alive")
defer s.mutex.Unlock() if runtime.GOOS == "windows" {
if _, err := os.FindProcess(s.command.Process.Pid); err != nil {
// process is already dead
return nil
}
} 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
}
} }
s.isRestarting = true slog.Debug("Process is still alive, sending signal")
dead := make(chan bool)
go func() {
s.command.Wait()
dead <- true
}()
if s.command.Process != nil {
var signal syscall.Signal var signal syscall.Signal
switch s.zqdgr.Config.ShutdownSignal { switch s.zqdgr.Config.ShutdownSignal {
case "SIGINT": case "SIGINT":
@@ -196,22 +196,10 @@ func (s *Script) Stop(lock bool) error {
signal = syscall.SIGKILL signal = syscall.SIGKILL
} }
// make sure the process is not dead
if s.command.ProcessState != nil && s.command.ProcessState.Exited() {
return nil
}
if err := syscall.Kill(-s.command.Process.Pid, signal); err != nil { if err := syscall.Kill(-s.command.Process.Pid, signal); err != nil {
log.Printf("error killing previous process: %v", err) log.Printf("error killing previous process: %v", err)
return err return err
} }
}
dead := make(chan bool)
go func() {
s.command.Wait()
dead <- true
}()
shutdownTimeout := s.zqdgr.Config.ShutdownTimeout shutdownTimeout := s.zqdgr.Config.ShutdownTimeout
if shutdownTimeout == 0 { if shutdownTimeout == 0 {
@@ -237,7 +225,7 @@ func (s *Script) Restart() error {
s.mutex.Lock() s.mutex.Lock()
err := s.Stop(false) err := s.Stop()
if err != nil { if err != nil {
s.mutex.Unlock() s.mutex.Unlock()
return err return err
@@ -251,8 +239,6 @@ func (s *Script) Restart() error {
return nil return nil
} }
s.isRestarting = false
s.mutex.Unlock() s.mutex.Unlock()
err = s.Start() err = s.Start()
@@ -274,10 +260,6 @@ func (s *Script) Restart() error {
return err return err
} }
func (s *Script) Wait() {
s.wg.Wait()
}
func (wsServer *WSServer) handleWs(w http.ResponseWriter, r *http.Request) { func (wsServer *WSServer) handleWs(w http.ResponseWriter, r *http.Request) {
conn, err := wsServer.upgrader.Upgrade(w, r, nil) conn, err := wsServer.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
@@ -319,42 +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 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() { func main() {
var err error // var err error
var debugMode bool var debugMode bool
noWs := flag.Bool("no-ws", false, "Disable WebSocket server") noWs := flag.Bool("no-ws", false, "Disable WebSocket server")
configDir := flag.String("config", ".", "Path to the config directory") 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") 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() flag.Parse()
debugModeVal, ok := os.LookupEnv("ZQDGR_DEBUG") // if ParseBool returns an error, ZQDGR_DEBUG is not a bool
if ok { debugMode, _ = strconv.ParseBool(os.Getenv("ZQDGR_DEBUG"))
debugMode, err = strconv.ParseBool(debugModeVal)
if err != nil {
log.Fatal(err)
}
if debugMode { if debugMode {
slog.SetLogLoggerLevel(slog.LevelDebug) slog.SetLogLoggerLevel(slog.LevelDebug)
} }
}
originalArgs := os.Args originalArgs := os.Args
os.Args = flag.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 { if zqdgr == nil {
return return
} }
@@ -362,10 +381,13 @@ func main() {
var command string var command string
var commandArgs []string var commandArgs []string
// command name trimmed by flags.Args()
// os.Args ~= [script, --, arguments]
for i, arg := range os.Args { for i, arg := range os.Args {
if arg == "--" { if arg == "--" {
if i+2 < len(os.Args) { slog.Debug("Found double-dash", "i", i, "len(os.Args)", len(os.Args), "os.Args", os.Args)
commandArgs = os.Args[i+2:] if len(os.Args)-i > 1 {
commandArgs = os.Args[i+1:]
} }
break break
} }
@@ -378,6 +400,8 @@ func main() {
commandArgs = append(commandArgs, arg) commandArgs = append(commandArgs, arg)
} }
slog.Debug("Collected", "command", command, "commandArgs", commandArgs)
watchMode := false watchMode := false
var scriptName string var scriptName string
switch command { switch command {
@@ -520,13 +544,7 @@ func main() {
log.Fatal("please specify a script to run") log.Fatal("please specify a script to run")
} }
watchMode = true watchMode = true
for i := range commandArgs { scriptName = commandArgs[0]
if strings.HasPrefix(commandArgs[i], "-") {
continue
}
scriptName = commandArgs[i]
}
default: default:
scriptName = command scriptName = command
} }
@@ -544,7 +562,9 @@ func main() {
log.Println("Received signal, exiting...") log.Println("Received signal, exiting...")
if script.command != nil { if script.command != nil {
script.Stop(true) script.mutex.Lock()
script.Stop()
script.mutex.Unlock()
} }
os.Exit(0) os.Exit(0)
@@ -568,48 +588,21 @@ func main() {
} }
// make sure the pattern is valid // make sure the pattern is valid
var paternArray []string patternArray, err := validatePattern(zqdgr.Config.Pattern)
var currentPattern string if err != nil {
inMatch := false log.Fatal(err)
// 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 for _, pattern := range zqdgr.Config.ExcludedGlobs {
_, err := validatePattern(pattern)
if err != nil {
log.Fatal(err)
} }
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)
}
if inMatch {
log.Fatal("unmatched } in pattern")
}
if currentPattern != "" {
paternArray = append(paternArray, currentPattern)
} }
watcherConfig := WatcherConfig{ watcherConfig := WatcherConfig{
excludedGlobs: globList(zqdgr.Config.ExcludedGlobs), excludedGlobs: globList(zqdgr.Config.ExcludedGlobs),
pattern: paternArray, pattern: patternArray,
} }
watcher, err := NewWatcher(&watcherConfig) watcher, err := NewWatcher(&watcherConfig)
@@ -629,13 +622,12 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
// We use this timer to deduplicate events. // tailing edge debounce of file system events
var ( var (
// Wait 100ms for new events; each new event resets the timer.
waitFor = 100 * time.Millisecond 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) timers = make(map[string]*time.Timer)
) )
go func() { go func() {
@@ -657,7 +649,15 @@ func main() {
if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { 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) slog.Debug("File changed", "file", event.Name)
if strings.HasSuffix(event.Name, "zqdgr.config.json") { 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 // re-exec the exact same command
if !*disableReloadConfig { if !*disableReloadConfig {
fmt.Println("zqdgr.config.json has changed, restarting...") fmt.Println("zqdgr.config.json has changed, restarting...")
@@ -666,11 +666,15 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
err = script.Stop(true) script.mutex.Lock()
err = script.Stop()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
script.mutex.Unlock()
err = syscall.Exec(executable, originalArgs, os.Environ()) err = syscall.Exec(executable, originalArgs, os.Environ())
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -680,11 +684,18 @@ func main() {
} }
} }
if pathShouldBeTracked(&watcherConfig, event.Name) && event.Op&fsnotify.Create == fsnotify.Create { if pathShouldBeTracked(&watcherConfig, event.Name) {
if event.Op&fsnotify.Create == fsnotify.Create {
slog.Debug("Adding new file to watcher", "file", event.Name) slog.Debug("Adding new file to watcher", "file", event.Name)
watcher.(NotifyWatcher).watcher.Add(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) { if pathMatches(&watcherConfig, event.Name) {
script.Restart() script.Restart()
} }
@@ -715,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() // block until the script exits
os.Exit(script.exitCode) os.Exit(<-script.exitCode)
} }

View File

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