feat: publish the Sandwich Hime source preview

Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
2026-08-11 20:15:06 -04:00
commit 9b29b3d7f8
100 changed files with 10989 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Package devserver implements Hime-san's local-only development supervisor.
// It is intentionally independent from the template compiler and production
// runtime.
package devserver
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
const (
// ConfigVersion is the himesan.json schema version understood by this
// package.
ConfigVersion = 1
defaultListenAddressEnv = "HIMESAN_LISTEN_ADDR"
defaultHealthPath = "/"
defaultProxyAddress = "127.0.0.1:7331"
)
var environmentNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Config is the versioned, non-secret himesan.json development configuration.
// Arguments are passed directly to the application; they are never interpreted
// by a shell.
type Config struct {
Version int `json:"version"`
SourceRoots []string `json:"sourceRoots"`
GoPackage string `json:"goPackage"`
AppArgs []string `json:"appArgs,omitempty"`
ListenAddressEnv string `json:"listenAddressEnv"`
HealthPath string `json:"healthPath"`
ProxyAddress string `json:"proxyAddress"`
AdditionalWatchRoots []string `json:"additionalWatchRoots,omitempty"`
}
// DefaultConfig returns safe defaults for a simple, single-module project.
func DefaultConfig() Config {
return Config{
Version: ConfigVersion,
SourceRoots: []string{"."},
GoPackage: ".",
ListenAddressEnv: defaultListenAddressEnv,
HealthPath: defaultHealthPath,
ProxyAddress: defaultProxyAddress,
}
}
// LoadConfig reads a himesan.json file, applies defaults for omitted optional
// fields, rejects unknown fields, and validates the result. Paths remain
// relative to the project root supplied later through Options.RootDir.
func LoadConfig(path string) (Config, error) {
f, err := os.Open(path)
if err != nil {
return Config{}, fmt.Errorf("open development config: %w", err)
}
defer f.Close()
cfg := DefaultConfig()
// Unlike optional fields, the schema version must be written explicitly so
// future defaults cannot silently reinterpret an old file.
cfg.Version = 0
decoder := json.NewDecoder(f)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&cfg); err != nil {
return Config{}, fmt.Errorf("decode development config: %w", err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return Config{}, errors.New("decode development config: multiple JSON values")
}
return Config{}, fmt.Errorf("decode development config: %w", err)
}
if err := cfg.Validate(); err != nil {
return Config{}, fmt.Errorf("validate development config: %w", err)
}
return cfg, nil
}
// Validate checks the schema and all values that do not require filesystem
// access. In particular, the stable proxy is restricted to loopback.
func (c Config) Validate() error {
if c.Version != ConfigVersion {
return fmt.Errorf("unsupported config version %d (want %d)", c.Version, ConfigVersion)
}
if len(c.SourceRoots) == 0 {
return errors.New("sourceRoots must contain at least one path")
}
for _, root := range append(append([]string(nil), c.SourceRoots...), c.AdditionalWatchRoots...) {
if err := validatePathValue(root); err != nil {
return err
}
}
if strings.TrimSpace(c.GoPackage) == "" {
return errors.New("goPackage must not be empty")
}
if strings.ContainsAny(c.GoPackage, "\x00\r\n") {
return errors.New("goPackage contains a control character")
}
for _, arg := range c.AppArgs {
if strings.ContainsRune(arg, '\x00') {
return errors.New("appArgs contains a NUL byte")
}
}
if !environmentNamePattern.MatchString(c.ListenAddressEnv) {
return fmt.Errorf("listenAddressEnv %q is not a valid environment variable name", c.ListenAddressEnv)
}
if !strings.HasPrefix(c.HealthPath, "/") || strings.HasPrefix(c.HealthPath, "//") {
return errors.New("healthPath must be an absolute URL path")
}
if strings.ContainsAny(c.HealthPath, "\x00\r\n?#") {
return errors.New("healthPath must not contain controls, a query, or a fragment")
}
if err := ValidateLoopbackAddress(c.ProxyAddress); err != nil {
return fmt.Errorf("proxyAddress: %w", err)
}
return nil
}
func validatePathValue(path string) error {
if strings.TrimSpace(path) == "" {
return errors.New("watch paths must not be empty")
}
if strings.ContainsRune(path, '\x00') {
return errors.New("watch path contains a NUL byte")
}
return nil
}
// ValidateLoopbackAddress rejects wildcard, public, malformed, and
// hostname-based proxy bindings. Requiring a literal loopback IP prevents a
// hosts-file or DNS change from broadening the development server's exposure.
func ValidateLoopbackAddress(address string) error {
host, port, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("must be host:port: %w", err)
}
portNumber, err := strconv.Atoi(port)
if err != nil || portNumber < 0 || portNumber > 65535 {
return fmt.Errorf("port %q is not numeric or is outside 0-65535", port)
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return fmt.Errorf("host %q is not a loopback IP", host)
}
return nil
}
func resolveProjectPath(rootDir, path string) string {
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
return filepath.Join(rootDir, filepath.Clean(path))
}
+69
View File
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadConfigDefaultsAndRejectsUnknownFields(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "himesan.json")
if err := os.WriteFile(path, []byte(`{"version":1,"proxyAddress":"[::1]:0"}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if cfg.GoPackage != "." || cfg.ListenAddressEnv != defaultListenAddressEnv || cfg.HealthPath != "/" {
t.Fatalf("LoadConfig() did not apply defaults: %#v", cfg)
}
if err := os.WriteFile(path, []byte(`{"version":1,"mystery":true}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadConfig(path); err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("LoadConfig() unknown field error = %v", err)
}
if err := os.WriteFile(path, []byte(`{"proxyAddress":"127.0.0.1:0"}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadConfig(path); err == nil || !strings.Contains(err.Error(), "version") {
t.Fatalf("LoadConfig() missing version error = %v", err)
}
}
func TestConfigValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*Config)
}{
{"public proxy", func(c *Config) { c.ProxyAddress = "0.0.0.0:7331" }},
{"hostname proxy", func(c *Config) { c.ProxyAddress = "localhost:7331" }},
{"bad port", func(c *Config) { c.ProxyAddress = "127.0.0.1:http" }},
{"bad environment", func(c *Config) { c.ListenAddressEnv = "bad-name" }},
{"health query", func(c *Config) { c.HealthPath = "/health?full=1" }},
{"empty source roots", func(c *Config) { c.SourceRoots = nil }},
{"nul argument", func(c *Config) { c.AppArgs = []string{"a\x00b"} }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := DefaultConfig()
test.mutate(&cfg)
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() unexpectedly succeeded")
}
})
}
for _, address := range []string{"127.0.0.1:0", "127.12.3.4:65535", "[::1]:7331"} {
if err := ValidateLoopbackAddress(address); err != nil {
t.Errorf("ValidateLoopbackAddress(%q) = %v", address, err)
}
}
}
+178
View File
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
const eventsPath = "/__himesan/events"
// Diagnostic is a compiler/build diagnostic suitable for the development
// browser overlay. The CLI may map its compiler's native diagnostics through
// Options.MapDiagnostics without coupling this package to the compiler.
type Diagnostic struct {
Path string `json:"path,omitempty"`
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
Severity string `json:"severity,omitempty"`
}
// Event is delivered both to Options.OnEvent and to connected browser clients.
// Type is currently one of "ready", "reload", or "diagnostic".
type Event struct {
Type string `json:"type"`
Phase string `json:"phase,omitempty"`
Message string `json:"message,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
At time.Time `json:"at"`
}
type eventHub struct {
mu sync.Mutex
subscribers map[chan Event]struct{}
latest *Event
closed bool
}
func newEventHub() *eventHub {
return &eventHub{subscribers: make(map[chan Event]struct{})}
}
func (h *eventHub) publish(event Event) {
if event.At.IsZero() {
event.At = time.Now().UTC()
}
h.mu.Lock()
defer h.mu.Unlock()
if h.closed {
return
}
if event.Type == "diagnostic" {
copy := event
h.latest = &copy
} else if event.Type == "reload" {
h.latest = nil
}
for subscriber := range h.subscribers {
select {
case subscriber <- event:
default:
// Reload and diagnostic events are snapshots, not a log. Replace the
// oldest queued snapshot so a slow browser still receives the newest
// state transition.
select {
case <-subscriber:
default:
}
select {
case subscriber <- event:
default:
}
}
}
}
func (h *eventHub) subscribe() (<-chan Event, func()) {
updates := make(chan Event, 8)
h.mu.Lock()
if h.closed {
close(updates)
h.mu.Unlock()
return updates, func() {}
}
h.subscribers[updates] = struct{}{}
if h.latest != nil {
updates <- *h.latest
}
h.mu.Unlock()
var once sync.Once
return updates, func() {
once.Do(func() {
h.mu.Lock()
if _, ok := h.subscribers[updates]; ok {
delete(h.subscribers, updates)
close(updates)
}
h.mu.Unlock()
})
}
}
func (h *eventHub) close() {
h.mu.Lock()
defer h.mu.Unlock()
if h.closed {
return
}
h.closed = true
for subscriber := range h.subscribers {
close(subscriber)
delete(h.subscribers, subscriber)
}
}
func (h *eventHub) serveHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming is unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
if err := writeSSE(w, Event{Type: "ready", At: time.Now().UTC()}); err != nil {
return
}
flusher.Flush()
updates, unsubscribe := h.subscribe()
defer unsubscribe()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-r.Context().Done():
return
case event, ok := <-updates:
if !ok {
return
}
if err := writeSSE(w, event); err != nil {
return
}
flusher.Flush()
case <-heartbeat.C:
if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
func writeSSE(w http.ResponseWriter, event Event) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: %s\n", event.Type); err != nil {
return err
}
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
return err
}
+127
View File
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"context"
"errors"
"os"
"os/exec"
"strconv"
"sync"
"time"
)
type candidateProcess struct {
command *exec.Cmd
address string
binaryPath string
processTree uintptr
exited chan struct{}
mu sync.Mutex
waitErr error
}
func taskkillArguments(pid int, force bool) []string {
arguments := []string{"/PID", strconv.Itoa(pid), "/T"}
if force {
arguments = append(arguments, "/F")
}
return arguments
}
func startManagedProcess(command *exec.Cmd, address, binaryPath string) (*candidateProcess, error) {
configureProcess(command)
if err := command.Start(); err != nil {
return nil, err
}
processTree, err := attachProcessTree(command)
if err != nil {
// Never return an unmanaged child. In particular, a Windows candidate
// must be attached to its Job Object before it can be considered usable.
_ = killProcess(command, 0)
_ = command.Wait()
return nil, errors.New("attach managed process tree: " + err.Error())
}
candidate := &candidateProcess{
command: command,
address: address,
binaryPath: binaryPath,
processTree: processTree,
exited: make(chan struct{}),
}
go func() {
err := command.Wait()
candidate.mu.Lock()
candidate.waitErr = err
candidate.mu.Unlock()
close(candidate.exited)
}()
return candidate, nil
}
func (c *candidateProcess) result() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.waitErr
}
func (c *candidateProcess) hasExited() bool {
select {
case <-c.exited:
return true
default:
return false
}
}
func (c *candidateProcess) cleanupProcessTree() error {
c.mu.Lock()
processTree := c.processTree
c.processTree = 0
c.mu.Unlock()
return cleanupProcess(c.command, processTree)
}
func (c *candidateProcess) stop(ctx context.Context) error {
defer func() {
if c.binaryPath != "" {
_ = os.Remove(c.binaryPath)
}
}()
if c.hasExited() {
return errors.Join(acceptableStopError(c.result()), c.cleanupProcessTree())
}
if err := terminateProcess(c.command, c.processTree); err != nil {
// A graceful signal is best effort. Failure to deliver it immediately
// escalates to the platform's process-tree termination primitive.
_ = killProcess(c.command, c.processTree)
}
select {
case <-c.exited:
return errors.Join(acceptableStopError(c.result()), c.cleanupProcessTree())
case <-ctx.Done():
killErr := killProcess(c.command, c.processTree)
select {
case <-c.exited:
return errors.Join(ctx.Err(), killErr, acceptableStopError(c.result()), c.cleanupProcessTree())
case <-time.After(2 * time.Second):
// Closing a Windows Job Object configured with
// KILL_ON_JOB_CLOSE is the final bounded fallback. On Unix this
// repeats the process-group kill without retaining resources.
return errors.Join(ctx.Err(), killErr, c.cleanupProcessTree())
}
}
}
func acceptableStopError(err error) error {
if err == nil {
return nil
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return nil
}
return err
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"context"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestManagedProcessStopsAndWaits(t *testing.T) {
command := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
command.Env = append(os.Environ(), "HIMESAN_PROCESS_HELPER=1")
candidate, err := startManagedProcess(command, "127.0.0.1:1", "")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := candidate.stop(ctx); err != nil {
t.Fatalf("stop() error = %v", err)
}
if !candidate.hasExited() {
t.Fatal("candidate process was not reaped")
}
}
func TestManagedProcessStopsDescendantTree(t *testing.T) {
if testing.Short() {
t.Skip("helper-process integration test")
}
directory := t.TempDir()
gatePath := filepath.Join(directory, "start-child")
readyPath := filepath.Join(directory, "child-address")
command := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
command.Env = append(os.Environ(),
"HIMESAN_PROCESS_HELPER=tree-parent",
"HIMESAN_PROCESS_GATE="+gatePath,
"HIMESAN_PROCESS_READY="+readyPath,
)
candidate, err := startManagedProcess(command, "127.0.0.1:1", "")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(gatePath, []byte("start"), 0o600); err != nil {
t.Fatal(err)
}
address := waitForChildAddress(t, readyPath)
waitForChildListener(t, address)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := candidate.stop(ctx); err != nil {
t.Fatalf("stop() error = %v", err)
}
if !candidate.hasExited() {
t.Fatal("candidate root process was not reaped")
}
deadline := time.Now().Add(2 * time.Second)
for {
connection, dialErr := net.DialTimeout("tcp", address, 100*time.Millisecond)
if dialErr != nil {
break
}
_ = connection.Close()
if time.Now().After(deadline) {
t.Fatalf("managed descendant still accepts connections at %s", address)
}
time.Sleep(20 * time.Millisecond)
}
}
func TestTaskkillArguments(t *testing.T) {
t.Parallel()
if got, want := taskkillArguments(42, false), []string{"/PID", "42", "/T"}; !reflect.DeepEqual(got, want) {
t.Fatalf("taskkillArguments(graceful) = %q, want %q", got, want)
}
if got, want := taskkillArguments(42, true), []string{"/PID", "42", "/T", "/F"}; !reflect.DeepEqual(got, want) {
t.Fatalf("taskkillArguments(force) = %q, want %q", got, want)
}
}
func TestManagedProcessHelper(t *testing.T) {
switch os.Getenv("HIMESAN_PROCESS_HELPER") {
case "":
return
case "tree-parent":
runTreeParentHelper()
case "tree-child":
runTreeChildHelper()
}
signals := make(chan os.Signal, 1)
signal.Notify(signals)
<-signals
os.Exit(0)
}
func runTreeParentHelper() {
gatePath := os.Getenv("HIMESAN_PROCESS_GATE")
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(gatePath); err == nil {
break
}
if time.Now().After(deadline) {
os.Exit(2)
}
time.Sleep(10 * time.Millisecond)
}
child := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
child.Env = replaceEnvironment(os.Environ(), "HIMESAN_PROCESS_HELPER", "tree-child")
if err := child.Start(); err != nil {
os.Exit(2)
}
}
func runTreeChildHelper() {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
os.Exit(2)
}
defer listener.Close()
if err := os.WriteFile(os.Getenv("HIMESAN_PROCESS_READY"), []byte(listener.Addr().String()), 0o600); err != nil {
os.Exit(2)
}
for {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
os.Exit(0)
}
_ = connection.Close()
}
}
func waitForChildAddress(t *testing.T, readyPath string) string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
contents, err := os.ReadFile(readyPath)
if err == nil && strings.TrimSpace(string(contents)) != "" {
return strings.TrimSpace(string(contents))
}
if time.Now().After(deadline) {
t.Fatalf("managed descendant did not report its address: %v", err)
}
time.Sleep(10 * time.Millisecond)
}
}
func waitForChildListener(t *testing.T, address string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var lastErr error
for {
connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
if err == nil {
_ = connection.Close()
return
}
lastErr = err
if time.Now().After(deadline) {
t.Fatalf("managed descendant did not accept a connection at %s: %v", address, lastErr)
}
time.Sleep(20 * time.Millisecond)
}
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-only
//go:build !windows
package devserver
import (
"errors"
"os/exec"
"syscall"
)
func configureProcess(command *exec.Cmd) {
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
func attachProcessTree(_ *exec.Cmd) (uintptr, error) { return 0, nil }
func terminateProcess(command *exec.Cmd, _ uintptr) error {
if command.Process == nil {
return nil
}
if err := syscall.Kill(-command.Process.Pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) {
return command.Process.Signal(syscall.SIGTERM)
}
return nil
}
func killProcess(command *exec.Cmd, _ uintptr) error {
if command.Process == nil {
return nil
}
if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return command.Process.Kill()
}
return nil
}
func cleanupProcess(command *exec.Cmd, processTree uintptr) error {
return killProcess(command, processTree)
}
+134
View File
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: AGPL-3.0-only
//go:build windows
package devserver
import (
"context"
"errors"
"fmt"
"os/exec"
"syscall"
"time"
"unsafe"
)
const (
processSetQuota = 0x0100
jobObjectExtendedLimitInformation = 9
jobObjectLimitKillOnJobClose = 0x00002000
)
type ioCounters struct {
ReadOperationCount uint64
WriteOperationCount uint64
OtherOperationCount uint64
ReadTransferCount uint64
WriteTransferCount uint64
OtherTransferCount uint64
}
type jobObjectExtendedLimitInfo struct {
BasicLimitInformation jobObjectBasicLimitInfo
IOInfo ioCounters
ProcessMemoryLimit uintptr
JobMemoryLimit uintptr
PeakProcessMemoryUsed uintptr
PeakJobMemoryUsed uintptr
}
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
assignProcessToJobObject = kernel32.NewProc("AssignProcessToJobObject")
createJobObjectW = kernel32.NewProc("CreateJobObjectW")
generateConsoleCtrlEvent = kernel32.NewProc("GenerateConsoleCtrlEvent")
setInformationJobObject = kernel32.NewProc("SetInformationJobObject")
terminateJobObject = kernel32.NewProc("TerminateJobObject")
)
func configureProcess(command *exec.Cmd) {
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
}
// attachProcessTree places the candidate in a Windows Job Object. Job
// membership is inherited by descendants, so they remain terminable even when
// the root process exits before cleanup reaches it.
func attachProcessTree(command *exec.Cmd) (uintptr, error) {
if command.Process == nil {
return 0, errors.New("candidate process is unavailable")
}
job, _, createErr := createJobObjectW.Call(0, 0)
if job == 0 {
return 0, fmt.Errorf("CreateJobObjectW: %w", createErr)
}
limits := jobObjectExtendedLimitInfo{}
limits.BasicLimitInformation.LimitFlags = jobObjectLimitKillOnJobClose
configured, _, configureErr := setInformationJobObject.Call(
job,
jobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&limits)),
unsafe.Sizeof(limits),
)
if configured == 0 {
_ = syscall.CloseHandle(syscall.Handle(job))
return 0, fmt.Errorf("SetInformationJobObject: %w", configureErr)
}
process, err := syscall.OpenProcess(processSetQuota|syscall.PROCESS_TERMINATE, false, uint32(command.Process.Pid))
if err != nil {
_ = syscall.CloseHandle(syscall.Handle(job))
return 0, fmt.Errorf("open candidate for Job Object assignment: %w", err)
}
defer syscall.CloseHandle(process)
assigned, _, assignErr := assignProcessToJobObject.Call(job, uintptr(process))
if assigned == 0 {
_ = syscall.CloseHandle(syscall.Handle(job))
return 0, fmt.Errorf("AssignProcessToJobObject: %w", assignErr)
}
return job, nil
}
func terminateProcess(command *exec.Cmd, _ uintptr) error {
if command.Process == nil {
return nil
}
result, _, callErr := generateConsoleCtrlEvent.Call(syscall.CTRL_BREAK_EVENT, uintptr(command.Process.Pid))
if result == 0 {
return fmt.Errorf("GenerateConsoleCtrlEvent: %w", callErr)
}
return nil
}
func killProcess(command *exec.Cmd, processTree uintptr) error {
if processTree != 0 {
result, _, callErr := terminateJobObject.Call(processTree, 1)
if result != 0 {
return nil
}
return fmt.Errorf("TerminateJobObject: %w", callErr)
}
if command.Process == nil {
return nil
}
if err := runTaskkill(command.Process.Pid, true); err != nil {
return errors.Join(err, command.Process.Kill())
}
return nil
}
func cleanupProcess(command *exec.Cmd, processTree uintptr) error {
if processTree == 0 {
// A successfully returned Windows candidate always owns a Job Object.
// Zero therefore means cleanup already ran; do not target a potentially
// recycled process ID.
return nil
}
terminateErr := killProcess(command, processTree)
closeErr := syscall.CloseHandle(syscall.Handle(processTree))
return errors.Join(terminateErr, closeErr)
}
func runTaskkill(pid int, force bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return exec.CommandContext(ctx, "taskkill", taskkillArguments(pid, force)...).Run()
}
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-only
//go:build windows && (386 || arm)
package devserver
import "unsafe"
// jobObjectBasicLimitInfo mirrors JOBOBJECT_BASIC_LIMIT_INFORMATION. Windows
// 32-bit ABIs pad this structure to an eight-byte boundary.
type jobObjectBasicLimitInfo struct {
PerProcessUserTimeLimit int64
PerJobUserTimeLimit int64
LimitFlags uint32
MinimumWorkingSetSize uintptr
MaximumWorkingSetSize uintptr
ActiveProcessLimit uint32
Affinity uintptr
PriorityClass uint32
SchedulingClass uint32
_ uint32
}
var (
_ [48 - unsafe.Sizeof(jobObjectBasicLimitInfo{})]byte
_ [unsafe.Sizeof(jobObjectBasicLimitInfo{}) - 48]byte
_ [112 - unsafe.Sizeof(jobObjectExtendedLimitInfo{})]byte
_ [unsafe.Sizeof(jobObjectExtendedLimitInfo{}) - 112]byte
)
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-only
//go:build windows && (amd64 || arm64)
package devserver
import "unsafe"
// jobObjectBasicLimitInfo mirrors JOBOBJECT_BASIC_LIMIT_INFORMATION on the
// supported 64-bit Windows architectures.
type jobObjectBasicLimitInfo struct {
PerProcessUserTimeLimit int64
PerJobUserTimeLimit int64
LimitFlags uint32
MinimumWorkingSetSize uintptr
MaximumWorkingSetSize uintptr
ActiveProcessLimit uint32
Affinity uintptr
PriorityClass uint32
SchedulingClass uint32
}
var (
_ [64 - unsafe.Sizeof(jobObjectBasicLimitInfo{})]byte
_ [unsafe.Sizeof(jobObjectBasicLimitInfo{}) - 64]byte
_ [144 - unsafe.Sizeof(jobObjectExtendedLimitInfo{})]byte
_ [unsafe.Sizeof(jobObjectExtendedLimitInfo{}) - 144]byte
)
+410
View File
@@ -0,0 +1,410 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"sync/atomic"
)
const (
maxInjectableHTML = 16 << 20
reloadClient = `(function(){var id="__himesan_overlay";function show(e){var d=document.getElementById(id);if(!d){d=document.createElement("dialog");d.id=id;var b=document.createElement("button");b.textContent="Close";b.addEventListener("click",function(){d.close()});var p=document.createElement("pre");d.appendChild(b);d.appendChild(p);document.body.appendChild(d)}var p=d.querySelector("pre"),xs=e.diagnostics||[];p.textContent=(e.phase?e.phase+": ":"")+(e.message||"Hime-san could not reload")+(xs.length?"\n\n"+xs.map(function(x){return (x.path||"")+(x.line?":"+x.line+(x.column?":"+x.column:""):"")+(x.code?" ["+x.code+"]":"")+" "+x.message}).join("\n"):"");if(!d.open)d.showModal()}var s=new EventSource("/__himesan/events");s.addEventListener("reload",function(){location.reload()});s.addEventListener("diagnostic",function(e){try{show(JSON.parse(e.data))}catch(_){show({message:e.data})}})})();`
)
var (
reloadClientTag = []byte("<script data-himesan-reload>" + reloadClient + "</script>")
reloadClientHash = makeReloadClientHash()
)
func makeReloadClientHash() string {
digest := sha256.Sum256([]byte(reloadClient))
return "'sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + "'"
}
type developmentProxy struct {
target atomic.Pointer[url.URL]
authority atomic.Pointer[localProxyAuthority]
hub *eventHub
proxy *httputil.ReverseProxy
}
type localProxyAuthority struct {
port int
}
func newDevelopmentProxy(hub *eventHub) *developmentProxy {
d := &developmentProxy{hub: hub}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
d.proxy = &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(request *httputil.ProxyRequest) {
target := d.target.Load()
if target == nil {
return
}
request.SetURL(target)
request.SetXForwarded()
request.Out.Header.Set("Accept-Encoding", "identity")
request.Out.Header.Del("If-Modified-Since")
request.Out.Header.Del("If-None-Match")
request.Out.Header.Set("Cache-Control", "no-cache")
},
ModifyResponse: injectDevelopmentClient,
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
http.Error(w, "Hime-san development upstream is unavailable: "+err.Error(), http.StatusBadGateway)
},
}
return d
}
func (d *developmentProxy) closeIdleConnections() {
if transport, ok := d.proxy.Transport.(interface{ CloseIdleConnections() }); ok {
transport.CloseIdleConnections()
}
}
func (d *developmentProxy) setAuthority(address string) error {
if err := ValidateLoopbackAddress(address); err != nil {
return fmt.Errorf("development proxy authority %q: %w", address, err)
}
_, rawPort, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("split development proxy authority: %w", err)
}
port, err := strconv.Atoi(rawPort)
if err != nil {
return fmt.Errorf("parse development proxy port: %w", err)
}
d.authority.Store(&localProxyAuthority{port: port})
return nil
}
func (d *developmentProxy) setTarget(address string) error {
if err := ValidateLoopbackAddress(address); err != nil {
return fmt.Errorf("development upstream %q: %w", address, err)
}
target, err := url.Parse("http://" + address)
if err != nil {
return fmt.Errorf("parse development upstream: %w", err)
}
d.target.Store(target)
return nil
}
func (d *developmentProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if status, message := d.validateRequest(r); status != 0 {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.Error(w, message, status)
return
}
if r.URL.Path == eventsPath {
d.hub.serveHTTP(w, r)
return
}
if d.target.Load() == nil {
d.serveWaitingPage(w)
return
}
d.proxy.ServeHTTP(w, r)
}
func (d *developmentProxy) validateRequest(r *http.Request) (int, string) {
authority := d.authority.Load()
if authority == nil {
return http.StatusServiceUnavailable, "Hime-san development proxy is not ready"
}
requestAuthority, ok := canonicalLoopbackAuthority(r.Host, authority.port)
if !ok {
return http.StatusMisdirectedRequest, "Hime-san development proxy requires a loopback Host authority"
}
if values := r.Header.Values("Sec-Fetch-Site"); len(values) > 1 {
return http.StatusForbidden, "cross-origin development proxy request denied"
} else if len(values) == 1 {
switch strings.ToLower(strings.TrimSpace(values[0])) {
case "", "none", "same-origin":
default:
return http.StatusForbidden, "cross-origin development proxy request denied"
}
}
origins := r.Header.Values("Origin")
if len(origins) > 1 {
return http.StatusForbidden, "cross-origin development proxy request denied"
}
if len(origins) == 1 {
originAuthority, ok := canonicalHTTPOrigin(origins[0], authority.port)
if !ok || originAuthority != requestAuthority {
return http.StatusForbidden, "cross-origin development proxy request denied"
}
}
return 0, ""
}
func canonicalHTTPOrigin(raw string, port int) (string, bool) {
if raw == "" || strings.TrimSpace(raw) != raw {
return "", false
}
origin, err := url.Parse(raw)
if err != nil || !strings.EqualFold(origin.Scheme, "http") || origin.Host == "" || origin.User != nil || origin.Opaque != "" || origin.Path != "" || origin.RawPath != "" || origin.RawQuery != "" || origin.Fragment != "" || origin.ForceQuery {
return "", false
}
return canonicalLoopbackAuthority(origin.Host, port)
}
func canonicalLoopbackAuthority(authority string, requiredPort int) (string, bool) {
if authority == "" || strings.TrimSpace(authority) != authority {
return "", false
}
host, rawPort, err := net.SplitHostPort(authority)
if err != nil {
if requiredPort != 80 {
return "", false
}
host = authority
rawPort = "80"
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
}
requestPort, err := strconv.Atoi(rawPort)
if err != nil || requestPort != requiredPort {
return "", false
}
normalizedHost := strings.ToLower(host)
if normalizedHost == "localhost" || normalizedHost == "localhost." {
return "localhost:" + strconv.Itoa(requiredPort), true
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return "", false
}
return ip.String() + ":" + strconv.Itoa(requiredPort), true
}
func (d *developmentProxy) serveWaitingPage(w http.ResponseWriter) {
body := append([]byte("<!doctype html><html><body><h1>Hime-san is waiting for a healthy application build.</h1>"), reloadClientTag...)
body = append(body, []byte("</body></html>")...)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src "+reloadClientHash+"; connect-src 'self'")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write(body)
}
func injectDevelopmentClient(response *http.Response) error {
disableDevelopmentCaching(response)
if !eligibleHTMLResponse(response) {
return nil
}
prefix, err := io.ReadAll(io.LimitReader(response.Body, maxInjectableHTML+1))
if err != nil {
return fmt.Errorf("read HTML for development reload injection: %w", err)
}
if len(prefix) > maxInjectableHTML {
response.Body = &prefixedReadCloser{
Reader: io.MultiReader(bytes.NewReader(prefix), response.Body),
Closer: response.Body,
}
return nil
}
if err := response.Body.Close(); err != nil {
return fmt.Errorf("close upstream HTML response: %w", err)
}
if !isFullHTMLDocument(prefix) {
response.Body = io.NopCloser(bytes.NewReader(prefix))
response.ContentLength = int64(len(prefix))
response.Header.Set("Content-Length", strconv.Itoa(len(prefix)))
return nil
}
contents := insertReloadClient(prefix)
response.Body = io.NopCloser(bytes.NewReader(contents))
response.ContentLength = int64(len(contents))
response.Header.Set("Content-Length", strconv.Itoa(len(contents)))
response.Header.Del("ETag")
response.Header.Del("Last-Modified")
adjustCSP(response.Header, "Content-Security-Policy")
adjustCSP(response.Header, "Content-Security-Policy-Report-Only")
return nil
}
func disableDevelopmentCaching(response *http.Response) {
response.Header.Set("Cache-Control", "no-store")
response.Header.Set("Pragma", "no-cache")
response.Header.Set("Expires", "0")
response.Header.Del("ETag")
response.Header.Del("Last-Modified")
}
func eligibleHTMLResponse(response *http.Response) bool {
if response.StatusCode != http.StatusOK || response.Request == nil || response.Body == nil || response.Request.Method == http.MethodHead {
return false
}
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(mediaType, "text/html") {
return false
}
if encoding := strings.TrimSpace(response.Header.Get("Content-Encoding")); encoding != "" && !strings.EqualFold(encoding, "identity") {
return false
}
rawDisposition := strings.TrimSpace(response.Header.Get("Content-Disposition"))
disposition, _, dispositionErr := mime.ParseMediaType(rawDisposition)
if strings.EqualFold(disposition, "attachment") || (dispositionErr != nil && strings.HasPrefix(strings.ToLower(rawDisposition), "attachment")) {
return false
}
request := response.Request
for _, header := range []string{"HX-Request", "Turbo-Frame", "X-PJAX", "X-Requested-With"} {
if strings.TrimSpace(request.Header.Get(header)) != "" {
return false
}
}
if strings.EqualFold(response.Header.Get("X-Himesan-Fragment"), "true") {
return false
}
destination := strings.TrimSpace(request.Header.Get("Sec-Fetch-Dest"))
return destination == "" || strings.EqualFold(destination, "document")
}
func isFullHTMLDocument(contents []byte) bool {
remaining := bytes.TrimSpace(contents)
remaining = bytes.TrimSpace(bytes.TrimPrefix(remaining, []byte{0xef, 0xbb, 0xbf}))
for bytes.HasPrefix(remaining, []byte("<!--")) {
end := bytes.Index(remaining[4:], []byte("-->"))
if end < 0 {
return false
}
remaining = bytes.TrimSpace(remaining[4+end+3:])
}
lower := bytes.ToLower(remaining)
return hasHTMLTokenPrefix(lower, "<!doctype html") || hasHTMLTokenPrefix(lower, "<html")
}
func hasHTMLTokenPrefix(contents []byte, prefix string) bool {
if !bytes.HasPrefix(contents, []byte(prefix)) || len(contents) == len(prefix) {
return false
}
next := contents[len(prefix)]
return next == '>' || next == '/' || next == ' ' || next == '\t' || next == '\r' || next == '\n' || next == '\f'
}
func insertReloadClient(contents []byte) []byte {
lower := bytes.ToLower(contents)
position := bytes.LastIndex(lower, []byte("</body>"))
if position < 0 {
position = bytes.LastIndex(lower, []byte("</html>"))
}
if position < 0 {
position = len(contents)
}
result := make([]byte, 0, len(contents)+len(reloadClientTag))
result = append(result, contents[:position]...)
result = append(result, reloadClientTag...)
result = append(result, contents[position:]...)
return result
}
func adjustCSP(header http.Header, name string) {
policies := header.Values(name)
if len(policies) == 0 {
return
}
header.Del(name)
for _, policy := range policies {
policy = addCSPSource(policy, "script-src", reloadClientHash, "default-src")
// CSP3 gives script-src-elem precedence over script-src for an inline
// <script>. Preserve that directive's restrictions while granting the
// same single hash, otherwise a policy such as script-src-elem 'none'
// silently blocks the injected reload client.
policy = addCSPSource(policy, "script-src-elem", reloadClientHash, "script-src")
policy = addCSPSource(policy, "connect-src", "'self'", "default-src")
header.Add(name, policy)
}
}
func addCSPSource(policy, directive, source, fallback string) string {
parts := strings.Split(policy, ";")
fallbackSources := []string(nil)
fallbackSeen := false
for index, raw := range parts {
fields := strings.Fields(raw)
if len(fields) == 0 {
continue
}
// CSP ignores duplicate directives after the first occurrence. Mirror
// that rule when deriving a missing directive from its fallback so an
// ignored, more-permissive duplicate cannot broaden the development page.
if !fallbackSeen && strings.EqualFold(fields[0], fallback) {
fallbackSeen = true
fallbackSources = append([]string(nil), fields[1:]...)
}
if !strings.EqualFold(fields[0], directive) {
continue
}
for _, existing := range fields[1:] {
if existing == source {
return strings.Join(parts, ";")
}
}
currentSources := withoutCSPNone(fields[1:])
parts[index] = fields[0]
if len(currentSources) != 0 {
parts[index] += " " + strings.Join(currentSources, " ")
}
parts[index] += " " + source
return strings.Join(parts, ";")
}
if !fallbackSeen {
// Without this directive or a default-src fallback the resource is
// already unrestricted; introducing a directive would unnecessarily
// restrict the application under test.
return policy
}
addition := directive
if retained := withoutCSPNone(fallbackSources); len(retained) != 0 {
addition += " " + strings.Join(retained, " ")
}
addition += " " + source
if strings.TrimSpace(policy) == "" {
return addition
}
if strings.HasSuffix(strings.TrimSpace(policy), ";") {
return policy + " " + addition
}
return policy + "; " + addition
}
func withoutCSPNone(sources []string) []string {
result := make([]string, 0, len(sources))
for _, source := range sources {
if !strings.EqualFold(source, "'none'") {
result = append(result, source)
}
}
return result
}
type prefixedReadCloser struct {
io.Reader
io.Closer
}
+390
View File
@@ -0,0 +1,390 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"bufio"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestInjectDevelopmentClientAndCSP(t *testing.T) {
t.Parallel()
body := "<!doctype html><html><body><h1>Hello</h1></body></html>"
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
request.Header.Set("Sec-Fetch-Dest", "document")
response := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Request: request,
}
response.Header.Set("Content-Type", "text/html; charset=utf-8")
response.Header.Set("Content-Length", strconv.Itoa(len(body)))
response.Header.Set("ETag", `"old"`)
response.Header.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; script-src-elem 'none'")
if err := injectDevelopmentClient(response); err != nil {
t.Fatalf("injectDevelopmentClient() error = %v", err)
}
got, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), string(reloadClientTag)) {
t.Fatalf("injected body does not contain reload client: %s", got)
}
if strings.Index(string(got), string(reloadClientTag)) > strings.Index(string(got), "</body>") {
t.Fatal("reload client was not inserted inside body")
}
policy := response.Header.Get("Content-Security-Policy")
if !strings.Contains(policy, reloadClientHash) || strings.Contains(policy, "unsafe-inline") {
t.Fatalf("CSP did not contain only the reload hash allowance: %q", policy)
}
scriptElementPolicy := cspDirective(policy, "script-src-elem")
if !strings.Contains(scriptElementPolicy, reloadClientHash) || strings.Contains(scriptElementPolicy, "'none'") {
t.Fatalf("CSP script-src-elem still blocks the reload client: %q", policy)
}
if !strings.Contains(policy, "connect-src") || !strings.Contains(policy, "'self'") {
t.Fatalf("CSP does not allow same-origin SSE: %q", policy)
}
if response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("ETag") != "" {
t.Fatalf("development cache headers = %#v", response.Header)
}
if response.ContentLength != int64(len(got)) {
t.Fatalf("ContentLength = %d, want %d", response.ContentLength, len(got))
}
}
func cspDirective(policy, name string) string {
for _, raw := range strings.Split(policy, ";") {
fields := strings.Fields(raw)
if len(fields) != 0 && strings.EqualFold(fields[0], name) {
return strings.Join(fields, " ")
}
}
return ""
}
func TestInjectionExcludesFragmentsAndNonHTML(t *testing.T) {
t.Parallel()
tests := []struct {
name string
contentType string
header string
method string
status int
}{
{name: "unmarked HTML fragment", contentType: "text/html", status: http.StatusOK},
{name: "htmx fragment", contentType: "text/html", header: "HX-Request", status: http.StatusOK},
{name: "turbo fragment", contentType: "text/html", header: "Turbo-Frame", status: http.StatusOK},
{name: "json api", contentType: "application/json", status: http.StatusOK},
{name: "HEAD response", contentType: "text/html", method: http.MethodHead, status: http.StatusOK},
{name: "no content", contentType: "text/html", status: http.StatusNoContent},
{name: "not modified", contentType: "text/html", status: http.StatusNotModified},
{name: "partial content", contentType: "text/html", status: http.StatusPartialContent},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body := "<p>fragment</p>"
method := test.method
if method == "" {
method = http.MethodGet
}
request := httptest.NewRequest(method, "http://example.test/items", nil)
if test.header != "" {
request.Header.Set(test.header, "true")
}
response := &http.Response{
StatusCode: test.status,
Header: http.Header{"Content-Type": []string{test.contentType}},
Body: io.NopCloser(strings.NewReader(body)),
Request: request,
}
if err := injectDevelopmentClient(response); err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Fatalf("fragment was modified: %q", got)
}
if response.Header.Get("Cache-Control") != "no-store" {
t.Fatal("fragment caching was not disabled")
}
})
}
}
func TestFullDocumentEvidenceAndCSPNone(t *testing.T) {
t.Parallel()
body := " \n<!-- generated -->\n<!DOCTYPE HTML><html><body>page</body></html>"
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
response := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"text/html"},
"Content-Security-Policy": []string{"default-src 'none'"},
},
Body: io.NopCloser(strings.NewReader(body)),
Request: request,
}
if err := injectDevelopmentClient(response); err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), string(reloadClientTag)) {
t.Fatal("full document with a leading comment was not injected")
}
policy := response.Header.Get("Content-Security-Policy")
if strings.Contains(policy, "script-src 'none'") || strings.Contains(policy, "connect-src 'none'") {
t.Fatalf("CSP 'none' was combined with an allowance: %q", policy)
}
if !strings.Contains(policy, "script-src "+reloadClientHash) || !strings.Contains(policy, "connect-src 'self'") {
t.Fatalf("CSP missing narrow development allowances: %q", policy)
}
}
func TestCSPFallbackUsesFirstDuplicateDirective(t *testing.T) {
t.Parallel()
for _, first := range []string{"'none'", ""} {
header := make(http.Header)
header.Set("Content-Security-Policy", "default-src "+first+"; default-src https://ignored-attacker.example")
adjustCSP(header, "Content-Security-Policy")
policy := header.Get("Content-Security-Policy")
for _, directive := range []string{"script-src", "script-src-elem"} {
value := cspDirective(policy, directive)
if !strings.Contains(value, reloadClientHash) || strings.Contains(value, "ignored-attacker.example") {
t.Fatalf("%s was broadened from an ignored duplicate fallback: %q", directive, policy)
}
}
}
}
func TestEventStream(t *testing.T) {
hub := newEventHub()
hub.publish(Event{Type: "diagnostic", Phase: "generate", Message: "broken before connect"})
server := httptest.NewServer(http.HandlerFunc(hub.serveHTTP))
t.Cleanup(func() {
hub.close()
server.Close()
})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
t.Fatal(err)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
scanner := bufio.NewScanner(response.Body)
ready := readSSEEvent(t, scanner)
if ready.Type != "ready" {
t.Fatalf("first event = %#v", ready)
}
replayed := readSSEEvent(t, scanner)
if replayed.Type != "diagnostic" || replayed.Message != "broken before connect" {
t.Fatalf("replayed event = %#v", replayed)
}
deadline := time.Now().Add(time.Second)
for {
hub.mu.Lock()
count := len(hub.subscribers)
hub.mu.Unlock()
if count != 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("SSE handler did not subscribe")
}
time.Sleep(time.Millisecond)
}
hub.publish(Event{Type: "diagnostic", Phase: "generate", Message: "broken"})
event := readSSEEvent(t, scanner)
if event.Type != "diagnostic" || event.Phase != "generate" || event.Message != "broken" {
t.Fatalf("streamed event = %#v", event)
}
}
func TestWaitingPageConnectsToEvents(t *testing.T) {
t.Parallel()
proxy := newDevelopmentProxy(newEventHub())
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:7331/", nil)
recorder := httptest.NewRecorder()
proxy.ServeHTTP(recorder, request)
result := recorder.Result()
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
if err != nil {
t.Fatal(err)
}
if result.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), string(reloadClientTag)) {
t.Fatalf("waiting response status/body = %d %q", result.StatusCode, body)
}
policy := result.Header.Get("Content-Security-Policy")
if !strings.Contains(policy, reloadClientHash) || strings.Contains(policy, "unsafe-inline") {
t.Fatalf("waiting page CSP = %q", policy)
}
}
func TestDevelopmentProxyRequiresLocalAuthorityAndSameOrigin(t *testing.T) {
t.Parallel()
var upstreamRequests atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamRequests.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(upstream.Close)
proxy := newDevelopmentProxy(newEventHub())
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
t.Fatal(err)
}
target, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
if err := proxy.setTarget(target.Host); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
host string
origin string
fetchSite string
wantStatus int
wantForwarded bool
}{
{name: "IPv4 loopback", host: "127.0.0.1:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
{name: "alternate loopback", host: "127.0.0.2:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
{name: "IPv6 loopback", host: "[::1]:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
{name: "localhost", host: "localhost:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
{name: "localhost trailing dot", host: "LOCALHOST.:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
{name: "same origin", host: "localhost:7331", origin: "http://localhost:7331", fetchSite: "same-origin", wantStatus: http.StatusNoContent, wantForwarded: true},
{name: "DNS rebinding host", host: "attacker.example:7331", wantStatus: http.StatusMisdirectedRequest},
{name: "localhost suffix", host: "localhost.attacker.example:7331", wantStatus: http.StatusMisdirectedRequest},
{name: "public IP host", host: "192.0.2.1:7331", wantStatus: http.StatusMisdirectedRequest},
{name: "wrong port", host: "127.0.0.1:7332", wantStatus: http.StatusMisdirectedRequest},
{name: "missing port", host: "127.0.0.1", wantStatus: http.StatusMisdirectedRequest},
{name: "cross origin", host: "127.0.0.1:7331", origin: "https://attacker.example", wantStatus: http.StatusForbidden},
{name: "different local origin", host: "127.0.0.1:7331", origin: "http://localhost:7331", wantStatus: http.StatusForbidden},
{name: "null origin", host: "127.0.0.1:7331", origin: "null", wantStatus: http.StatusForbidden},
{name: "cross site metadata", host: "127.0.0.1:7331", fetchSite: "cross-site", wantStatus: http.StatusForbidden},
{name: "same site but cross origin metadata", host: "localhost:7331", fetchSite: "same-site", wantStatus: http.StatusForbidden},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
before := upstreamRequests.Load()
request := httptest.NewRequest(http.MethodGet, "http://"+test.host+"/", nil)
request.Host = test.host
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
if test.fetchSite != "" {
request.Header.Set("Sec-Fetch-Site", test.fetchSite)
}
recorder := httptest.NewRecorder()
proxy.ServeHTTP(recorder, request)
if recorder.Code != test.wantStatus {
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, test.wantStatus, recorder.Body.String())
}
forwarded := upstreamRequests.Load() != before
if forwarded != test.wantForwarded {
t.Fatalf("forwarded = %v, want %v", forwarded, test.wantForwarded)
}
if !test.wantForwarded && recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatal("rejection was cacheable")
}
})
}
}
func TestDevelopmentProxyProtectsEventStream(t *testing.T) {
t.Parallel()
hub := newEventHub()
t.Cleanup(hub.close)
proxy := newDevelopmentProxy(hub)
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
t.Fatal(err)
}
rejected := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:7331"+eventsPath, nil)
rejected.Header.Set("Origin", "https://attacker.example")
rejectedRecorder := httptest.NewRecorder()
proxy.ServeHTTP(rejectedRecorder, rejected)
if rejectedRecorder.Code != http.StatusForbidden {
t.Fatalf("cross-origin event stream status = %d, want %d", rejectedRecorder.Code, http.StatusForbidden)
}
ctx, cancel := context.WithCancel(context.Background())
allowed := httptest.NewRequest(http.MethodGet, "http://localhost:7331"+eventsPath, nil).WithContext(ctx)
allowed.Header.Set("Origin", "http://localhost:7331")
cancel()
allowedRecorder := httptest.NewRecorder()
proxy.ServeHTTP(allowedRecorder, allowed)
if allowedRecorder.Code != http.StatusOK || !strings.Contains(allowedRecorder.Body.String(), "event: ready") {
t.Fatalf("same-origin event stream status/body = %d %q", allowedRecorder.Code, allowedRecorder.Body.String())
}
}
func TestEventHubRetainsNewestEventForSlowSubscriber(t *testing.T) {
t.Parallel()
hub := newEventHub()
updates, unsubscribe := hub.subscribe()
defer unsubscribe()
for index := 0; index < 20; index++ {
hub.publish(Event{Type: "diagnostic", Message: strconv.Itoa(index)})
}
hub.publish(Event{Type: "reload"})
var last Event
for len(updates) != 0 {
last = <-updates
}
if last.Type != "reload" {
t.Fatalf("newest queued event = %#v, want reload", last)
}
}
func readSSEEvent(t *testing.T, scanner *bufio.Scanner) Event {
t.Helper()
var data string
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
data = strings.TrimPrefix(line, "data: ")
}
if line == "" && data != "" {
var event Event
if err := json.Unmarshal([]byte(data), &event); err != nil {
t.Fatalf("decode SSE event: %v", err)
}
return event
}
}
t.Fatalf("SSE stream ended: %v", scanner.Err())
return Event{}
}
+505
View File
@@ -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 = &copy
}
// 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
}
+295
View File
@@ -0,0 +1,295 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
if testing.Short() {
t.Skip("integration test builds temporary Go applications")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-test\n\ngo 1.25\n"), 0o600); err != nil {
t.Fatal(err)
}
mainPath := filepath.Join(root, "main.go")
writeTestApplication(t, mainPath, "version one", true)
cfg := DefaultConfig()
cfg.ProxyAddress = "127.0.0.1:0"
cfg.HealthPath = "/healthz"
var generations atomic.Int32
events := make(chan Event, 32)
supervisor, err := New(Options{
RootDir: root,
Config: cfg,
Generate: func(context.Context) error {
generations.Add(1)
return nil
},
OnEvent: func(event Event) { events <- event },
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 25 * time.Millisecond,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
StartupTimeout: 750 * time.Millisecond,
ShutdownTimeout: 2 * time.Second,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
runResult := make(chan error, 1)
go func() { runResult <- supervisor.Run(ctx) }()
proxyAddress := waitForProxyAddress(t, supervisor)
waitForBody(t, "http://"+proxyAddress+"/", "version one")
firstUpstream := supervisor.proxy.target.Load().Host
if err := os.WriteFile(mainPath, []byte("package main\nfunc"), 0o600); err != nil {
t.Fatal(err)
}
waitForPhase(t, events, "build")
waitForBody(t, "http://"+proxyAddress+"/", "version one")
writeTestApplication(t, mainPath, "unhealthy candidate", false)
waitForPhase(t, events, "startup")
waitForBody(t, "http://"+proxyAddress+"/", "version one")
writeTestApplication(t, mainPath, "version two", true)
waitForBody(t, "http://"+proxyAddress+"/", "version two")
if generations.Load() < 4 {
t.Fatalf("Generate hook ran %d times, want at least 4", generations.Load())
}
secondUpstream := supervisor.proxy.target.Load().Host
if firstUpstream == secondUpstream {
t.Fatalf("healthy candidate was not swapped: %s", firstUpstream)
}
// The proxy target changes before graceful shutdown of the replaced child
// completes. Wait for that bounded cleanup instead of racing the supervisor
// immediately after the first response from the new target.
waitForConnectionRefused(t, firstUpstream, 3*time.Second)
cancel()
select {
case err := <-runResult:
if err != nil {
t.Fatalf("Run() error = %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Run() did not stop after cancellation")
}
waitForConnectionRefused(t, secondUpstream, 3*time.Second)
}
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
t.Parallel()
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "last good")
})}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = upstream.Close() })
go func() { _ = upstream.Serve(listener) }()
wantError := errors.New("templates are invalid")
events := make(chan Event, 1)
supervisor, err := New(Options{
RootDir: t.TempDir(),
Config: DefaultConfig(),
Generate: func(context.Context) error {
return wantError
},
OnEvent: func(event Event) { events <- event },
CacheDir: filepath.Join(t.TempDir(), "cache"),
})
if err != nil {
t.Fatal(err)
}
if err := supervisor.proxy.setTarget(listener.Addr().String()); err != nil {
t.Fatal(err)
}
wantTarget := supervisor.proxy.target.Load().String()
if candidate := supervisor.buildHealthyCandidate(context.Background()); candidate != nil {
t.Fatal("generation failure unexpectedly produced a candidate")
}
if got := supervisor.proxy.target.Load().String(); got != wantTarget {
t.Fatalf("proxy target changed from %q to %q", wantTarget, got)
}
select {
case event := <-events:
if event.Type != "diagnostic" || event.Phase != "generate" || !strings.Contains(event.Message, wantError.Error()) {
t.Fatalf("generation event = %#v", event)
}
case <-time.After(time.Second):
t.Fatal("generation diagnostic was not emitted")
}
}
func writeTestApplication(t *testing.T, path, message string, healthy bool) {
t.Helper()
healthStatus := "http.StatusNoContent"
if !healthy {
healthStatus = "http.StatusServiceUnavailable"
}
contents := fmt.Sprintf(`package main
import (
"fmt"
"net/http"
"os"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(%s) })
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, "<!doctype html><html><body>%s</body></html>")
})
if err := http.ListenAndServe(os.Getenv("HIMESAN_LISTEN_ADDR"), mux); err != nil {
panic(err)
}
}
`, healthStatus, message)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
func waitForPhase(t *testing.T, events <-chan Event, phase string) {
t.Helper()
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
for {
select {
case event := <-events:
if event.Type == "diagnostic" && event.Phase == phase {
return
}
case <-timer.C:
t.Fatalf("did not receive %s diagnostic", phase)
}
}
}
func waitForProxyAddress(t *testing.T, supervisor *Supervisor) string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if address := supervisor.ProxyAddress(); address != "" {
return address
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("development proxy did not start")
return ""
}
func waitForBody(t *testing.T, url, want string) {
t.Helper()
client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: time.Second}
deadline := time.Now().Add(10 * time.Second)
var last string
for time.Now().Before(deadline) {
response, err := client.Get(url)
if err == nil {
body, readErr := io.ReadAll(response.Body)
_ = response.Body.Close()
if readErr == nil {
last = string(body)
if response.StatusCode == http.StatusOK && strings.Contains(last, want) && strings.Contains(last, string(reloadClientTag)) {
return
}
}
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("proxy never served %q; last body = %q", want, last)
}
func waitForConnectionRefused(t *testing.T, address string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
if err != nil {
return
}
_ = connection.Close()
if time.Now().After(deadline) {
t.Fatalf("replaced child still accepts connections at %s after %s", address, timeout)
}
time.Sleep(25 * time.Millisecond)
}
}
func TestLimitedDiagnosticBuffer(t *testing.T) {
t.Parallel()
var buffer limitedDiagnosticBuffer
contents := strings.Repeat("x", maxDiagnosticOutput+100)
written, err := buffer.Write([]byte(contents))
if err != nil || written != len(contents) {
t.Fatalf("Write() = %d, %v", written, err)
}
if buffer.Buffer.Len() != maxDiagnosticOutput {
t.Fatalf("stored bytes = %d, want %d", buffer.Buffer.Len(), maxDiagnosticOutput)
}
if !strings.HasSuffix(buffer.String(), "diagnostic output truncated") {
t.Fatalf("String() did not report truncation: %q", buffer.String())
}
}
func TestReplaceEnvironment(t *testing.T) {
t.Parallel()
got := replaceEnvironment([]string{"A=one", "HIMESAN_LISTEN_ADDR=old", "B=two"}, "HIMESAN_LISTEN_ADDR", "127.0.0.1:1")
want := []string{"A=one", "B=two", "HIMESAN_LISTEN_ADDR=127.0.0.1:1"}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("replaceEnvironment() = %q, want %q", got, want)
}
}
func TestHealthCheckDoesNotFollowRedirectOffLoopback(t *testing.T) {
t.Parallel()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", "http://example.invalid/escaped")
w.WriteHeader(http.StatusFound)
}))
defer upstream.Close()
cfg := DefaultConfig()
supervisor, err := New(Options{
RootDir: t.TempDir(),
Config: cfg,
Generate: func(context.Context) error { return nil },
CacheDir: filepath.Join(t.TempDir(), "cache"),
})
if err != nil {
t.Fatal(err)
}
candidate := &candidateProcess{
address: strings.TrimPrefix(upstream.URL, "http://"),
exited: make(chan struct{}),
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := supervisor.waitUntilHealthy(ctx, candidate); err != nil {
t.Fatalf("loopback redirect status should be observed without following it: %v", err)
}
}
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
type watchRoot struct {
path string
allRegular bool
}
type fileFingerprint struct {
size int64
mode fs.FileMode
modTime time.Time
}
type fileSnapshot map[string]fileFingerprint
func makeWatchRoots(rootDir string, cfg Config) []watchRoot {
roots := make([]watchRoot, 0, 1+len(cfg.SourceRoots)+len(cfg.AdditionalWatchRoots))
// GoPackage may live outside a narrowly configured template source root.
// Watching the containing module (while respecting nested module boundaries)
// makes ordinary Go edits rebuild without asking users to duplicate roots.
rootDir = filepath.Clean(rootDir)
roots = append(roots, watchRoot{path: rootDir})
seen := map[string]bool{rootDir: true}
for _, root := range cfg.SourceRoots {
path := resolveProjectPath(rootDir, root)
if !seen[path] {
roots = append(roots, watchRoot{path: path})
seen[path] = true
}
}
for _, root := range cfg.AdditionalWatchRoots {
path := resolveProjectPath(rootDir, root)
if seen[path] {
for index := range roots {
if roots[index].path == path {
roots[index].allRegular = true
}
}
continue
}
roots = append(roots, watchRoot{path: path, allRegular: true})
seen[path] = true
}
return roots
}
func takeSnapshot(roots []watchRoot) (fileSnapshot, error) {
snapshot := make(fileSnapshot)
var problems []error
for _, root := range roots {
rootInfo, err := os.Lstat(root.path)
if err != nil {
problems = append(problems, fmt.Errorf("watch %s: %w", root.path, err))
continue
}
if rootInfo.Mode()&os.ModeSymlink != 0 {
problems = append(problems, fmt.Errorf("watch %s: symbolic-link roots are not followed", root.path))
continue
}
err = filepath.WalkDir(root.path, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
problems = append(problems, fmt.Errorf("watch %s: %w", path, walkErr))
if entry != nil && entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if path != root.path && entry.IsDir() {
if shouldSkipWatchDirectory(entry.Name()) {
return filepath.SkipDir
}
if !root.allRegular {
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
return filepath.SkipDir
}
}
return nil
}
if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() {
return nil
}
if strings.HasSuffix(strings.ToLower(entry.Name()), ".sando.go") {
return nil
}
if !root.allRegular && !isDevelopmentSource(path) {
return nil
}
info, err := entry.Info()
if err != nil {
problems = append(problems, fmt.Errorf("watch %s: %w", path, err))
return nil
}
if !info.Mode().IsRegular() {
return nil
}
snapshot[path] = fileFingerprint{
size: info.Size(),
mode: info.Mode(),
modTime: info.ModTime(),
}
return nil
})
if err != nil {
problems = append(problems, fmt.Errorf("watch %s: %w", root.path, err))
}
}
return snapshot, errors.Join(problems...)
}
func shouldSkipWatchDirectory(name string) bool {
switch name {
case ".git", ".hg", ".svn", ".himesan", "node_modules", "vendor":
return true
default:
return false
}
}
func isDevelopmentSource(path string) bool {
name := filepath.Base(path)
// Generated output is rebuilt from its .sando source and is therefore not a
// separate watch trigger. Excluding it prevents generation from causing a
// redundant build while still preserving edits made during an active build.
if strings.HasSuffix(strings.ToLower(name), ".sando.go") {
return false
}
switch name {
case "go.mod", "go.sum", "go.work", "go.work.sum", "himesan.json":
return true
}
extension := strings.ToLower(filepath.Ext(name))
return extension == ".go" || extension == ".sando"
}
func snapshotsEqual(left, right fileSnapshot) bool {
if len(left) != len(right) {
return false
}
for path, leftFingerprint := range left {
if rightFingerprint, ok := right[path]; !ok || rightFingerprint != leftFingerprint {
return false
}
}
return true
}
+59
View File
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"os"
"path/filepath"
"testing"
)
func TestSnapshotWatchesSourcesAssetsAndStopsAtNestedModules(t *testing.T) {
t.Parallel()
root := t.TempDir()
writeWatchFile(t, filepath.Join(root, "main.go"), "package main")
writeWatchFile(t, filepath.Join(root, "views", "home.sando"), "<h1>home</h1>")
writeWatchFile(t, filepath.Join(root, "views", "home.sando.go"), "// generated")
writeWatchFile(t, filepath.Join(root, "notes.txt"), "not watched")
writeWatchFile(t, filepath.Join(root, "assets", "site.css"), "body{}")
writeWatchFile(t, filepath.Join(root, "nested", "go.mod"), "module nested.test")
writeWatchFile(t, filepath.Join(root, "nested", "ignored.go"), "package ignored")
cfg := DefaultConfig()
cfg.SourceRoots = []string{"views"}
cfg.AdditionalWatchRoots = []string{"assets"}
snapshot, err := takeSnapshot(makeWatchRoots(root, cfg))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"main.go", filepath.Join("views", "home.sando"), filepath.Join("assets", "site.css")} {
if _, ok := snapshot[filepath.Join(root, want)]; !ok {
t.Errorf("snapshot does not contain %s", want)
}
}
for _, unwanted := range []string{"notes.txt", filepath.Join("views", "home.sando.go"), filepath.Join("nested", "go.mod"), filepath.Join("nested", "ignored.go")} {
if _, ok := snapshot[filepath.Join(root, unwanted)]; ok {
t.Errorf("snapshot unexpectedly contains %s", unwanted)
}
}
before := snapshot
writeWatchFile(t, filepath.Join(root, "assets", "site.css"), "body{color:green}")
after, err := takeSnapshot(makeWatchRoots(root, cfg))
if err != nil {
t.Fatal(err)
}
if snapshotsEqual(before, after) {
t.Fatal("asset change was not detected")
}
}
func writeWatchFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}