Skip to content

Commit

Permalink
feat: program executor extracted out to a separate package
Browse files Browse the repository at this point in the history
  • Loading branch information
nxtcoder17 committed Sep 21, 2024
1 parent 61bca9c commit 66bc757
Show file tree
Hide file tree
Showing 2 changed files with 177 additions and 103 deletions.
194 changes: 91 additions & 103 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,72 +3,22 @@ package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"

fswatcher "github.com/nxtcoder17/fwatcher/pkg/fs-watcher"
"github.com/nxtcoder17/fwatcher/pkg/executor"
"github.com/nxtcoder17/fwatcher/pkg/logging"
fswatcher "github.com/nxtcoder17/fwatcher/pkg/watcher"
"github.com/urfave/cli/v2"
)

type ProgramManager struct {
done chan os.Signal
logger logging.Logger
}

func NewProgramManager(logger logging.Logger) *ProgramManager {
done := make(chan os.Signal, 1)
signal.Notify(done, syscall.SIGTERM)

return &ProgramManager{
done: done,
logger: logger,
}
}

func (pm *ProgramManager) Exec(execCmd string) error {
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()

cmd := exec.CommandContext(ctx, "sh", "-c", execCmd)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin

cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

if err := cmd.Start(); err != nil {
return err
}

defer func() {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}()

go func() {
select {
case <-pm.done:
cancelFunc()
}
}()

if err := cmd.Wait(); err != nil {
if strings.HasPrefix(err.Error(), "signal:") {
pm.logger.Debug(fmt.Sprintf("wait terminated as %s received", err.Error()))
}
return err
}

return nil
}

func main() {
logger := logging.NewLogger()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
defer stop()

app := &cli.App{
Name: "fwatcher",
Expand All @@ -80,11 +30,18 @@ func main() {
Required: false,
Value: false,
},
&cli.BoolFlag{
Name: "no-default-ignore",
Usage: "disables ignoring from default ignore list",
Required: false,
Aliases: []string{"I"},
Value: false,
},
&cli.StringFlag{
Name: "exec",
Name: "command",
Usage: "specifies command to execute on file change",
Required: true,
// Aliases: []string{"exec"},
Required: false,
Aliases: []string{"c"},
},
&cli.PathFlag{
Name: "dir",
Expand All @@ -101,80 +58,111 @@ func main() {
EnvVars: nil,
},
&cli.StringSliceFlag{
Name: "extensions",
Usage: "file extensions to watch on",
Name: "ignore-suffixes",
Usage: "files suffixes to ignore",
Required: false,
Aliases: []string{"ext"},
},
&cli.StringSliceFlag{
Name: "ignore-extensions",
Usage: "file extensions to ignore watching on",
Required: false,
Aliases: []string{"iext"},
Aliases: []string{"i"},
},
&cli.StringSliceFlag{
Name: "exclude-dir",
Usage: "directory to exclude from watching",
Required: false,
Aliases: []string{"exclude"},
Aliases: []string{"x", "e"},
},
&cli.BoolFlag{
Name: "no-default-ignore",
Usage: "disables ignoring from default ignore list",
Required: false,
Aliases: []string{"I"},
Value: false,
},
},

Action: func(ctx *cli.Context) error {
logger.SetLogLevel(logging.InfoLevel)
isDebugMode := ctx.Bool("debug")
if isDebugMode {
logger.SetLogLevel(logging.DebugLevel)
}
Action: func(cctx *cli.Context) error {
logger := logging.NewSlogLogger(logging.SlogOptions{
ShowTimestamp: false,
ShowCaller: false,
ShowDebugLogs: cctx.Bool("debug"),
SetAsDefaultLogger: true,
})

pm := NewProgramManager(logger)
var execCmd string
var execArgs []string

if cctx.String("command") != "" {
s := strings.SplitN(cctx.String("command"), " ", 2)

switch len(s) {
case 0:
return fmt.Errorf("invalid command")
case 1:
execCmd = s[0]
execArgs = nil
case 2:
execCmd = s[0]
execArgs = strings.Split(s[1], " ")
}
} else {
logger.Debug("no command specified, using args")
if cctx.Args().Len() == 0 {
return fmt.Errorf("no command specified")
}

execCmd := ctx.String("exec")
execCmd = cctx.Args().First()
execArgs = cctx.Args().Tail()
}

watchExt := ctx.StringSlice("extensions")
ignoreExt := ctx.StringSlice("ignore-extensions")
excludeDirs := ctx.StringSlice("exclude-dir")
ex := executor.NewExecutor(ctx, executor.ExecutorArgs{
Logger: logger,
Command: func(context.Context) *exec.Cmd {
cmd := exec.Command(execCmd, execArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd
},
})

watcher := fswatcher.NewWatcher(fswatcher.WatcherCtx{
Logger: logger,
WatchExtensions: watchExt,
IgnoreExtensions: ignoreExt,
ExcludeDirs: excludeDirs,
watcher, err := fswatcher.NewWatcher(fswatcher.WatcherArgs{
Logger: logger,
IgnoreSuffixes: cctx.StringSlice("ignore-suffixes"),
ExcludeDirs: cctx.StringSlice("exclude-dir"),
UseDefaultIgnoreList: !cctx.Bool("no-global-ignore"),
})
if err := watcher.RecursiveAdd(ctx.String("dir")); err != nil {
if err != nil {
panic(err)
}

go pm.Exec(execCmd)
defer func() {
pm.done <- syscall.SIGKILL
}()
if err := watcher.RecursiveAdd(cctx.String("dir")); err != nil {
panic(err)
}

go ex.Exec()

go func() {
select {
case <-ctx.Done():
print("ending ...")
pm.done <- syscall.SIGKILL
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}
<-ctx.Done()
logger.Debug("fwatcher is closing ...")
<-time.After(200 * time.Millisecond)
os.Exit(0)
}()

watcher.WatchEvents(func(event fswatcher.Event, fp string) error {
pm.done <- syscall.SIGKILL
logger.Info(fmt.Sprintf("[RELOADING] due changes in %s", fp))
go pm.Exec(execCmd)
relPath, err := filepath.Rel(cctx.String("dir"), fp)
if err != nil {
return err
}
logger.Info(fmt.Sprintf("[RELOADING] due changes in %s", relPath))
ex.Kill()
<-time.After(100 * time.Millisecond)
go ex.Exec()
return nil
})

return nil
},
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
defer stop()

if err := app.RunContext(ctx, os.Args); err != nil {
log.Fatal(err)
os.Exit(1)
}
}
86 changes: 86 additions & 0 deletions pkg/executor/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package executor

import (
"context"
"log/slog"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
)

type Executor struct {
ctx context.Context
done chan os.Signal
logger *slog.Logger

newCmd func(context.Context) *exec.Cmd
mu sync.Mutex
}

type ExecutorArgs struct {
Logger *slog.Logger
Command func(context.Context) *exec.Cmd
}

func NewExecutor(ctx context.Context, args ExecutorArgs) *Executor {
done := make(chan os.Signal, 1)
signal.Notify(done, syscall.SIGTERM)

if args.Logger == nil {
args.Logger = slog.Default()
}

return &Executor{
done: done,
logger: args.Logger,
ctx: ctx,
newCmd: args.Command,
mu: sync.Mutex{},
}
}

func (ex *Executor) Exec() error {
ex.mu.Lock()
defer ex.mu.Unlock()

ex.logger.Debug("[exec] starting process")

ctx, cf := context.WithCancel(ex.ctx)
defer cf()

cmd := ex.newCmd(ctx)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

if err := cmd.Start(); err != nil {
return err
}

go func() {
select {
case <-ctx.Done():
ex.logger.Debug("process terminated", "pid", cmd.Process.Pid)
case <-ex.done:
ex.logger.Debug("executor terminated", "pid", cmd.Process.Pid)
}
ex.logger.Debug("[exec] killing process", "pid", cmd.Process.Pid)
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
ex.logger.Error("failed to kill process", "pid", cmd.Process.Pid, "err", err)
}
}()

if err := cmd.Wait(); err != nil {
if strings.HasPrefix(err.Error(), "signal:") {
ex.logger.Debug("wait terminated, received", "signal", err.Error())
}
return err
}

return nil
}

func (ex *Executor) Kill() {
ex.done <- os.Signal(syscall.SIGTERM)
}

0 comments on commit 66bc757

Please sign in to comment.