github.com/brandur/modulir@v0.0.0-20240305213423-94ee82929cbd/modulir.go (about)

     1  package modulir
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net/http"
     7  	"os"
     8  	"os/signal"
     9  	"sync"
    10  	"time"
    11  
    12  	"github.com/fsnotify/fsnotify"
    13  	"golang.org/x/sys/unix"
    14  	"golang.org/x/xerrors"
    15  )
    16  
    17  //////////////////////////////////////////////////////////////////////////////
    18  //
    19  //
    20  //
    21  // Public
    22  //
    23  //
    24  //
    25  //////////////////////////////////////////////////////////////////////////////
    26  
    27  // Config contains configuration.
    28  type Config struct {
    29  	// Concurrency is the number of concurrent workers to run during the build
    30  	// step.
    31  	//
    32  	// Defaults to 10.
    33  	Concurrency int
    34  
    35  	// Log specifies a logger to use.
    36  	//
    37  	// Defaults to an instance of Logger running at informational level.
    38  	Log LoggerInterface
    39  
    40  	// LogColor specifies whether messages sent to Log should be color. You may
    41  	// want to set to true if you know output is going to a terminal.
    42  	//
    43  	// Defaults to false.
    44  	LogColor bool
    45  
    46  	// Port specifies the port on which to serve content from TargetDir over
    47  	// HTTP.
    48  	//
    49  	// Defaults to not running if left unset.
    50  	Port int
    51  
    52  	// SourceDir is the directory containing source files.
    53  	//
    54  	// Defaults to ".".
    55  	SourceDir string
    56  
    57  	// TargetDir is the directory where the site will be built to.
    58  	//
    59  	// Defaults to "./public".
    60  	TargetDir string
    61  
    62  	// Websocket indicates that Modulir should be started in development
    63  	// mode with a websocket that provides features like live reload.
    64  	//
    65  	// Defaults to false.
    66  	Websocket bool
    67  }
    68  
    69  // Build is one of the main entry points to the program. Call this to build
    70  // only one time.
    71  func Build(config *Config, f func(*Context) []error) {
    72  	var buildCompleteMu sync.Mutex
    73  	buildComplete := sync.NewCond(&buildCompleteMu)
    74  	finish := make(chan struct{}, 1)
    75  
    76  	// Signal the build loop to finish immediately
    77  	finish <- struct{}{}
    78  
    79  	c := initContext(config, nil)
    80  	ensureTargetDir(c)
    81  
    82  	success := build(c, f, finish, buildComplete)
    83  	if !success {
    84  		os.Exit(1)
    85  	}
    86  }
    87  
    88  // BuildLoop is one of the main entry points to the program. Call this to build
    89  // in a perpetual loop.
    90  func BuildLoop(config *Config, f func(*Context) []error) {
    91  	var buildCompleteMu sync.Mutex
    92  	buildComplete := sync.NewCond(&buildCompleteMu)
    93  	finish := make(chan struct{}, 1)
    94  
    95  	watcher, err := fsnotify.NewWatcher()
    96  	if err != nil {
    97  		exitWithError(xerrors.Errorf("error starting watcher: %w", err))
    98  	}
    99  	defer watcher.Close()
   100  
   101  	c := initContext(config, watcher)
   102  	ensureTargetDir(c)
   103  
   104  	// Serve HTTP
   105  	var server *http.Server
   106  	go func() {
   107  		server = startServingTargetDirHTTP(c, buildComplete)
   108  	}()
   109  
   110  	// Run the build loop. Loops forever until receiving on finish.
   111  	go build(c, f, finish, buildComplete)
   112  
   113  	// Listen for signals. Modulir will gracefully exit and re-exec itself upon
   114  	// receipt of USR2.
   115  	signals := make(chan os.Signal, 1024)
   116  	signal.Notify(signals, unix.SIGUSR2)
   117  	for {
   118  		s := <-signals
   119  		if s == unix.SIGUSR2 {
   120  			shutdownAndExec(c, finish, watcher, server)
   121  		}
   122  	}
   123  }
   124  
   125  //////////////////////////////////////////////////////////////////////////////
   126  //
   127  //
   128  //
   129  // Private
   130  //
   131  //
   132  //
   133  //////////////////////////////////////////////////////////////////////////////
   134  
   135  // Runs an infinite built loop until a signal is received over the `finish`
   136  // channel.
   137  //
   138  // Returns true of the last build was successful and false otherwise.
   139  func build(c *Context, f func(*Context) []error,
   140  	finish chan struct{}, buildComplete *sync.Cond,
   141  ) bool {
   142  	rebuild := make(chan map[string]struct{})
   143  	rebuildDone := make(chan struct{})
   144  
   145  	if c.Watcher != nil {
   146  		go watchChanges(c, c.Watcher.Events, c.Watcher.Errors,
   147  			rebuild, rebuildDone)
   148  	}
   149  
   150  	// Paths that changed on the last loop (as discovered via fsnotify). If
   151  	// set, we go into quick build mode with only these paths activated, and
   152  	// unset them afterwards. This saves us doing lots of checks on the
   153  	// filesystem and makes jobs much faster to run.
   154  	var lastChangedSources map[string]struct{}
   155  
   156  	for {
   157  		c.Log.Debugf("Start loop")
   158  		c.ResetBuild()
   159  		c.StartRound()
   160  
   161  		if lastChangedSources != nil {
   162  			c.QuickPaths = lastChangedSources
   163  		}
   164  
   165  		errors := f(c)
   166  
   167  		var lastRoundErrors []error
   168  
   169  		// Do one wait round as the build loop might not have waited on its
   170  		// last phase, but only bother if it looks like any jobs were enqueued.
   171  		if len(c.Pool.Jobs) > 0 {
   172  			lastRoundErrors = c.Wait()
   173  		}
   174  
   175  		// Context's Wait restarts the pool, so wait on that one more time to
   176  		// shut it back down.
   177  		c.Pool.Wait()
   178  
   179  		buildDuration := time.Since(c.Stats.Start)
   180  
   181  		if lastRoundErrors != nil {
   182  			errors = append(errors, lastRoundErrors...)
   183  		}
   184  
   185  		c.Pool.LogErrorsSlice(errors)
   186  		c.Pool.LogSlowestSlice(c.Stats.JobsExecuted)
   187  
   188  		success := len(c.Stats.JobsErrored) == 0
   189  
   190  		c.Log.Infof(
   191  			c.colorizer.Bold(colorByStatus(c, "Built site in %s", success)).String()+
   192  				" (loop took %v; total non-parallel time %v)",
   193  			buildDuration.Truncate(100*time.Microsecond),
   194  			c.Stats.LoopDuration.Truncate(100*time.Microsecond),
   195  			calculateTotalDuration(c.Stats.JobsExecuted).Truncate(100*time.Microsecond),
   196  		)
   197  		c.Log.Infof(
   198  			"%v of %v job(s) did work in %v round(s); "+
   199  				c.colorizer.Bold(colorByStatus(c, "%v errored", success)).String(),
   200  			len(c.Stats.JobsExecuted), c.Stats.NumJobs, c.Stats.NumRounds, len(c.Stats.JobsErrored),
   201  		)
   202  
   203  		c.QuickPaths = nil
   204  
   205  		buildComplete.Broadcast()
   206  
   207  		if c.FirstRun {
   208  			c.FirstRun = false
   209  		} else {
   210  			rebuildDone <- struct{}{}
   211  		}
   212  
   213  		select {
   214  		case <-finish:
   215  			c.Log.Infof("Build loop detected finish signal; stopping")
   216  			return len(errors) < 1
   217  
   218  		case lastChangedSources = <-rebuild:
   219  			c.Log.Infof("Build loop detected change on %v; rebuilding",
   220  				mapKeys(lastChangedSources))
   221  		}
   222  	}
   223  }
   224  
   225  func colorByStatus(c *Context, s string, success bool) string {
   226  	if success {
   227  		return c.colorizer.Green(s).String()
   228  	}
   229  
   230  	return c.colorizer.Red(s).String()
   231  }
   232  
   233  // Calculates the total duration given a set of jobs.
   234  func calculateTotalDuration(jobs []*Job) time.Duration {
   235  	var totalTime time.Duration
   236  	for _, job := range jobs {
   237  		totalTime += job.Duration
   238  	}
   239  	return totalTime
   240  }
   241  
   242  // Ensures that the configured TargetDir exists. We want to do this early (i.e.
   243  // before the build loop) so that we can start the HTTP server right away
   244  // instead of waiting for a build.
   245  func ensureTargetDir(c *Context) {
   246  	if err := os.MkdirAll(c.TargetDir, 0o755); err != nil {
   247  		exitWithError(xerrors.Errorf("error creating target directory: %w", err))
   248  	}
   249  }
   250  
   251  // Exits with status 1 after printing the given error to stderr.
   252  func exitWithError(err error) {
   253  	fmt.Fprintf(os.Stderr, "error: %v\n", err)
   254  	os.Exit(1)
   255  }
   256  
   257  // Takes a Modulir configuration and initializes it with defaults for any
   258  // properties that weren't expressly filled in.
   259  func initConfigDefaults(config *Config) *Config {
   260  	if config == nil {
   261  		config = &Config{}
   262  	}
   263  
   264  	if config.Concurrency <= 0 {
   265  		config.Concurrency = 50
   266  	}
   267  
   268  	if config.Log == nil {
   269  		config.Log = &Logger{Level: LevelInfo}
   270  	}
   271  
   272  	if config.SourceDir == "" {
   273  		config.SourceDir = "."
   274  	}
   275  
   276  	if config.TargetDir == "" {
   277  		config.TargetDir = "./public"
   278  	}
   279  
   280  	return config
   281  }
   282  
   283  // Initializes a new Modulir context from the given configuration.
   284  func initContext(config *Config, watcher *fsnotify.Watcher) *Context {
   285  	config = initConfigDefaults(config)
   286  
   287  	return NewContext(&Args{
   288  		Log:       config.Log,
   289  		LogColor:  config.LogColor,
   290  		Port:      config.Port,
   291  		Pool:      NewPool(config.Log, config.Concurrency),
   292  		SourceDir: config.SourceDir,
   293  		TargetDir: config.TargetDir,
   294  		Watcher:   watcher,
   295  		Websocket: config.Websocket,
   296  	})
   297  }
   298  
   299  // Extract the names of keys out of a map and return them as a slice.
   300  func mapKeys(m map[string]struct{}) []string {
   301  	keys := make([]string, 0, len(m))
   302  	for key := range m {
   303  		keys = append(keys, key)
   304  	}
   305  	return keys
   306  }
   307  
   308  // Replaces the current process with a fresh one by invoking the same
   309  // executable with the operating system's exec syscall. This is prompted by the
   310  // USR2 signal and is intended to allow the process to refresh itself in the
   311  // case where it's source files changed and it was recompiled.
   312  //
   313  // The fsnotify watcher and HTTP server are shut down as gracefully as possible
   314  // before the replacement occurs.
   315  func shutdownAndExec(c *Context, finish chan struct{},
   316  	watcher *fsnotify.Watcher, server *http.Server,
   317  ) {
   318  	// Tell the build loop to finish up
   319  	finish <- struct{}{}
   320  
   321  	// DANGER: Defers don't seem to get called on the re-exec, so even though
   322  	// we have a defer which closes our watcher, it won't close, leading to
   323  	// file descriptor leaking. Close it manually here instead.
   324  	watcher.Close()
   325  
   326  	// A context that will act as a timeout for connections
   327  	// that are still running as we try and shut down the HTTP
   328  	// server.
   329  	timeoutCtx, cancel := context.WithTimeout(
   330  		context.Background(),
   331  		5*time.Second,
   332  	)
   333  
   334  	c.Log.Infof("Shutting down HTTP server")
   335  	if err := server.Shutdown(timeoutCtx); err != nil {
   336  		exitWithError(err)
   337  	}
   338  
   339  	cancel()
   340  
   341  	// Returns an absolute path.
   342  	execPath, err := os.Executable()
   343  	if err != nil {
   344  		exitWithError(err)
   345  	}
   346  
   347  	c.Log.Infof("Execing process '%s' with args %+v\n", execPath, os.Args)
   348  	if err := unix.Exec(execPath, os.Args, os.Environ()); err != nil {
   349  		exitWithError(err)
   350  	}
   351  }