feat: publish the Sandwich Hime source preview
Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxDiagnosticOutput = 64 << 10
|
||||
|
||||
// GenerateFunc regenerates affected .sando.go files. The supervisor does not
|
||||
// import the compiler: the CLI supplies this hook.
|
||||
type GenerateFunc func(context.Context) error
|
||||
|
||||
// Options configures a Supervisor. Durations and output writers have safe
|
||||
// defaults when omitted.
|
||||
type Options struct {
|
||||
RootDir string
|
||||
Config Config
|
||||
Generate GenerateFunc
|
||||
MapDiagnostics func(error) []Diagnostic
|
||||
OnEvent func(Event)
|
||||
|
||||
Output io.Writer
|
||||
ErrorOutput io.Writer
|
||||
GoCommand string
|
||||
CacheDir string
|
||||
|
||||
PollInterval time.Duration
|
||||
Debounce time.Duration
|
||||
BuildTimeout time.Duration
|
||||
StartupTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Supervisor owns the local proxy, watcher, build candidates, and current
|
||||
// healthy application child.
|
||||
type Supervisor struct {
|
||||
options Options
|
||||
rootDir string
|
||||
cacheDir string
|
||||
|
||||
hub *eventHub
|
||||
proxy *developmentProxy
|
||||
|
||||
running atomic.Bool
|
||||
addressMu sync.RWMutex
|
||||
address string
|
||||
}
|
||||
|
||||
// New validates and normalizes a local development supervisor without opening
|
||||
// listeners or starting processes.
|
||||
func New(options Options) (*Supervisor, error) {
|
||||
if err := options.Config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("development config: %w", err)
|
||||
}
|
||||
if options.Generate == nil {
|
||||
return nil, errors.New("development generate hook is required")
|
||||
}
|
||||
options.Config.SourceRoots = append([]string(nil), options.Config.SourceRoots...)
|
||||
options.Config.AppArgs = append([]string(nil), options.Config.AppArgs...)
|
||||
options.Config.AdditionalWatchRoots = append([]string(nil), options.Config.AdditionalWatchRoots...)
|
||||
rootDir := options.RootDir
|
||||
if rootDir == "" {
|
||||
var err error
|
||||
rootDir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get project directory: %w", err)
|
||||
}
|
||||
}
|
||||
rootDir, err := filepath.Abs(rootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve project directory: %w", err)
|
||||
}
|
||||
info, err := os.Stat(rootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect project directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("project root %q is not a directory", rootDir)
|
||||
}
|
||||
|
||||
applyOptionDefaults(&options)
|
||||
cacheDir := options.CacheDir
|
||||
if cacheDir == "" {
|
||||
userCache, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locate user cache directory: %w", err)
|
||||
}
|
||||
key := sha256.Sum256([]byte(rootDir + "\x00" + options.Config.GoPackage))
|
||||
cacheDir = filepath.Join(userCache, "himesan", "dev", hex.EncodeToString(key[:8]))
|
||||
} else if !filepath.IsAbs(cacheDir) {
|
||||
cacheDir = filepath.Join(rootDir, cacheDir)
|
||||
}
|
||||
|
||||
hub := newEventHub()
|
||||
return &Supervisor{
|
||||
options: options,
|
||||
rootDir: rootDir,
|
||||
cacheDir: filepath.Clean(cacheDir),
|
||||
hub: hub,
|
||||
proxy: newDevelopmentProxy(hub),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyOptionDefaults(options *Options) {
|
||||
if options.Output == nil {
|
||||
options.Output = io.Discard
|
||||
}
|
||||
if options.ErrorOutput == nil {
|
||||
options.ErrorOutput = io.Discard
|
||||
}
|
||||
if options.GoCommand == "" {
|
||||
options.GoCommand = "go"
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
options.PollInterval = 250 * time.Millisecond
|
||||
}
|
||||
if options.Debounce <= 0 {
|
||||
options.Debounce = 150 * time.Millisecond
|
||||
}
|
||||
if options.BuildTimeout <= 0 {
|
||||
options.BuildTimeout = 2 * time.Minute
|
||||
}
|
||||
if options.StartupTimeout <= 0 {
|
||||
options.StartupTimeout = 10 * time.Second
|
||||
}
|
||||
if options.ShutdownTimeout <= 0 {
|
||||
options.ShutdownTimeout = 5 * time.Second
|
||||
}
|
||||
if options.HTTPClient == nil {
|
||||
options.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
Timeout: time.Second,
|
||||
}
|
||||
} else {
|
||||
copy := *options.HTTPClient
|
||||
options.HTTPClient = ©
|
||||
}
|
||||
// Health redirects are status results, not permission to leave loopback.
|
||||
options.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyAddress reports the bound stable proxy address after Run has opened its
|
||||
// listener. It is useful when Config.ProxyAddress requests port zero in tests.
|
||||
func (s *Supervisor) ProxyAddress() string {
|
||||
s.addressMu.RLock()
|
||||
defer s.addressMu.RUnlock()
|
||||
return s.address
|
||||
}
|
||||
|
||||
// Run serves until ctx is canceled or the stable proxy fails. A generation,
|
||||
// build, startup, or health-check failure is reported as a diagnostic event and
|
||||
// leaves the last healthy child serving.
|
||||
func (s *Supervisor) Run(ctx context.Context) error {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
return errors.New("development supervisor may only be run once")
|
||||
}
|
||||
if err := os.MkdirAll(s.cacheDir, 0o700); err != nil {
|
||||
return fmt.Errorf("create development cache: %w", err)
|
||||
}
|
||||
if err := os.Chmod(s.cacheDir, 0o700); err != nil {
|
||||
return fmt.Errorf("secure development cache: %w", err)
|
||||
}
|
||||
listener, err := net.Listen("tcp", s.options.Config.ProxyAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on development proxy: %w", err)
|
||||
}
|
||||
if err := s.proxy.setAuthority(listener.Addr().String()); err != nil {
|
||||
_ = listener.Close()
|
||||
return err
|
||||
}
|
||||
s.addressMu.Lock()
|
||||
s.address = listener.Addr().String()
|
||||
s.addressMu.Unlock()
|
||||
|
||||
server := &http.Server{
|
||||
Handler: s.proxy,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 75 * time.Second,
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
err := server.Serve(listener)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
serverErrors <- err
|
||||
}()
|
||||
|
||||
var current *candidateProcess
|
||||
defer func() {
|
||||
s.hub.close()
|
||||
s.proxy.closeIdleConnections()
|
||||
serverCtx, cancelServer := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = server.Shutdown(serverCtx)
|
||||
cancelServer()
|
||||
if current != nil {
|
||||
processCtx, cancelProcess := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = current.stop(processCtx)
|
||||
cancelProcess()
|
||||
}
|
||||
}()
|
||||
|
||||
s.emit(Event{Type: "ready", Phase: "proxy", Message: "http://" + listener.Addr().String()})
|
||||
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
|
||||
current = s.activateCandidate(candidate, current)
|
||||
}
|
||||
|
||||
roots := makeWatchRoots(s.rootDir, s.options.Config)
|
||||
snapshot, snapshotErr := takeSnapshot(roots)
|
||||
lastWatchError := ""
|
||||
if snapshotErr != nil {
|
||||
lastWatchError = snapshotErr.Error()
|
||||
s.report("watch", snapshotErr)
|
||||
}
|
||||
ticker := time.NewTicker(s.options.PollInterval)
|
||||
defer ticker.Stop()
|
||||
var pending bool
|
||||
var changedAt time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-serverErrors:
|
||||
if err != nil {
|
||||
return fmt.Errorf("development proxy: %w", err)
|
||||
}
|
||||
return nil
|
||||
case now := <-ticker.C:
|
||||
next, watchErr := takeSnapshot(roots)
|
||||
watchError := ""
|
||||
if watchErr != nil {
|
||||
watchError = watchErr.Error()
|
||||
}
|
||||
if watchError != "" && watchError != lastWatchError {
|
||||
s.report("watch", watchErr)
|
||||
}
|
||||
lastWatchError = watchError
|
||||
if !snapshotsEqual(snapshot, next) {
|
||||
snapshot = next
|
||||
pending = true
|
||||
changedAt = now
|
||||
}
|
||||
if current != nil && current.hasExited() {
|
||||
exitErr := current.result()
|
||||
if exitErr == nil {
|
||||
exitErr = errors.New("application exited")
|
||||
} else {
|
||||
exitErr = fmt.Errorf("application exited: %w", exitErr)
|
||||
}
|
||||
s.report("run", exitErr)
|
||||
_ = current.cleanupProcessTree()
|
||||
_ = os.Remove(current.binaryPath)
|
||||
current = nil
|
||||
}
|
||||
if pending && now.Sub(changedAt) >= s.options.Debounce {
|
||||
pending = false
|
||||
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
|
||||
current = s.activateCandidate(candidate, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) buildHealthyCandidate(ctx context.Context) *candidateProcess {
|
||||
if err := s.options.Generate(ctx); err != nil {
|
||||
s.report("generate", err)
|
||||
return nil
|
||||
}
|
||||
binaryPath, err := s.build(ctx)
|
||||
if err != nil {
|
||||
s.report("build", err)
|
||||
return nil
|
||||
}
|
||||
candidate, err := s.startAndCheck(ctx, binaryPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
s.report("startup", err)
|
||||
return nil
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func (s *Supervisor) build(ctx context.Context) (string, error) {
|
||||
buildCtx, cancel := context.WithTimeout(ctx, s.options.BuildTimeout)
|
||||
defer cancel()
|
||||
template := "candidate-*"
|
||||
if runtime.GOOS == "windows" {
|
||||
template += ".exe"
|
||||
}
|
||||
placeholder, err := os.CreateTemp(s.cacheDir, template)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reserve candidate binary: %w", err)
|
||||
}
|
||||
binaryPath := placeholder.Name()
|
||||
if err := placeholder.Close(); err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
return "", fmt.Errorf("close candidate placeholder: %w", err)
|
||||
}
|
||||
if err := os.Remove(binaryPath); err != nil {
|
||||
return "", fmt.Errorf("prepare candidate binary: %w", err)
|
||||
}
|
||||
command := exec.CommandContext(buildCtx, s.options.GoCommand, "build", "-o", binaryPath, "--", s.options.Config.GoPackage)
|
||||
command.Dir = s.rootDir
|
||||
var diagnostics limitedDiagnosticBuffer
|
||||
command.Stdout = io.MultiWriter(s.options.Output, &diagnostics)
|
||||
command.Stderr = io.MultiWriter(s.options.ErrorOutput, &diagnostics)
|
||||
if err := command.Run(); err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
message := diagnostics.String()
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("go build failed: %s", message)
|
||||
}
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) startAndCheck(ctx context.Context, binaryPath string) (*candidateProcess, error) {
|
||||
address, err := unusedLoopbackAddress()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command := exec.Command(binaryPath, s.options.Config.AppArgs...)
|
||||
command.Dir = s.rootDir
|
||||
command.Env = replaceEnvironment(os.Environ(), s.options.Config.ListenAddressEnv, address)
|
||||
command.Stdout = s.options.Output
|
||||
command.Stderr = s.options.ErrorOutput
|
||||
candidate, err := startManagedProcess(command, address, binaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start candidate: %w", err)
|
||||
}
|
||||
startupCtx, cancel := context.WithTimeout(ctx, s.options.StartupTimeout)
|
||||
defer cancel()
|
||||
if err := s.waitUntilHealthy(startupCtx, candidate); err != nil {
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
defer stopCancel()
|
||||
_ = candidate.stop(stopCtx)
|
||||
return nil, err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func unusedLoopbackAddress() (string, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reserve candidate address: %w", err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
if err := listener.Close(); err != nil {
|
||||
return "", fmt.Errorf("release candidate address: %w", err)
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) waitUntilHealthy(ctx context.Context, candidate *candidateProcess) error {
|
||||
url := "http://" + candidate.address + s.options.Config.HealthPath
|
||||
ticker := time.NewTicker(75 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var lastError error
|
||||
for {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create health request: %w", err)
|
||||
}
|
||||
response, err := s.options.HTTPClient.Do(request)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 400 {
|
||||
return nil
|
||||
}
|
||||
lastError = fmt.Errorf("health endpoint returned %s", response.Status)
|
||||
} else {
|
||||
lastError = err
|
||||
}
|
||||
select {
|
||||
case <-candidate.exited:
|
||||
exitErr := candidate.result()
|
||||
if exitErr == nil {
|
||||
return errors.New("candidate exited before becoming healthy")
|
||||
}
|
||||
return fmt.Errorf("candidate exited before becoming healthy: %w", exitErr)
|
||||
case <-ctx.Done():
|
||||
if lastError == nil {
|
||||
lastError = ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("candidate did not become healthy: %w", lastError)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) activateCandidate(candidate, previous *candidateProcess) *candidateProcess {
|
||||
if err := s.proxy.setTarget(candidate.address); err != nil {
|
||||
s.report("proxy", err)
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
defer cancel()
|
||||
_ = candidate.stop(stopCtx)
|
||||
return previous
|
||||
}
|
||||
s.emit(Event{Type: "reload", Phase: "serve", Message: "healthy application activated"})
|
||||
if previous != nil {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = previous.stop(stopCtx)
|
||||
cancel()
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func (s *Supervisor) emit(event Event) {
|
||||
if event.At.IsZero() {
|
||||
event.At = time.Now().UTC()
|
||||
}
|
||||
s.hub.publish(event)
|
||||
if s.options.OnEvent != nil {
|
||||
s.options.OnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) report(phase string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
event := Event{Type: "diagnostic", Phase: phase, Message: truncateDiagnostic(err.Error())}
|
||||
if s.options.MapDiagnostics != nil {
|
||||
event.Diagnostics = s.options.MapDiagnostics(err)
|
||||
}
|
||||
s.emit(event)
|
||||
}
|
||||
|
||||
func replaceEnvironment(environment []string, name, value string) []string {
|
||||
result := make([]string, 0, len(environment)+1)
|
||||
for _, item := range environment {
|
||||
itemName, _, ok := strings.Cut(item, "=")
|
||||
matches := ok && itemName == name
|
||||
if runtime.GOOS == "windows" {
|
||||
matches = ok && strings.EqualFold(itemName, name)
|
||||
}
|
||||
if matches {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return append(result, name+"="+value)
|
||||
}
|
||||
|
||||
func truncateDiagnostic(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if len(message) <= maxDiagnosticOutput {
|
||||
return message
|
||||
}
|
||||
return strings.ToValidUTF8(message[:maxDiagnosticOutput], "�") + "\n… diagnostic output truncated"
|
||||
}
|
||||
|
||||
type limitedDiagnosticBuffer struct {
|
||||
bytes.Buffer
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (b *limitedDiagnosticBuffer) Write(contents []byte) (int, error) {
|
||||
originalLength := len(contents)
|
||||
remaining := maxDiagnosticOutput - b.Buffer.Len()
|
||||
writtenLength := 0
|
||||
if remaining > 0 {
|
||||
if len(contents) > remaining {
|
||||
contents = contents[:remaining]
|
||||
}
|
||||
writtenLength, _ = b.Buffer.Write(contents)
|
||||
}
|
||||
if originalLength > writtenLength {
|
||||
b.truncated = true
|
||||
}
|
||||
return originalLength, nil
|
||||
}
|
||||
|
||||
func (b *limitedDiagnosticBuffer) String() string {
|
||||
message := strings.TrimSpace(strings.ToValidUTF8(b.Buffer.String(), "�"))
|
||||
if b.truncated {
|
||||
message += "\n… diagnostic output truncated"
|
||||
}
|
||||
return message
|
||||
}
|
||||
Reference in New Issue
Block a user