mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(implementation-sweep): run one campaign against every implementation of a requirement
installs, builds and serves each implementation on its own port, then hands the campaign tool the same seed slice, step budget and generator for all of them, so a difference between implementations is not a difference in exploration. the generator and platform are fixed rather than exposed.
This commit is contained in:
1 parent
a45ba76d8e
commit
146152a3af
8 files changed
+1830
No files matched your search
@@ -0,0 +1,482 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The test binary doubles as the stub implementation's server and as the
|
||||||
|
// fetcher the stub campaign uses, so the sweep drives a real preview process
|
||||||
|
// over a real port and the served URL is answered by a real HTTP server.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
switch {
|
||||||
|
case os.Getenv("SWEEP_TEST_SERVE_PORT") != "":
|
||||||
|
serveUntilKilled(os.Getenv("SWEEP_TEST_SERVE_PORT"))
|
||||||
|
case os.Getenv("SWEEP_TEST_FETCH_URL") != "":
|
||||||
|
recordFetch(
|
||||||
|
os.Getenv("SWEEP_TEST_FETCH_URL"),
|
||||||
|
os.Getenv("SWEEP_TEST_FETCH_LOG"),
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveUntilKilled(port string) {
|
||||||
|
handler := http.HandlerFunc(
|
||||||
|
func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
fmt.Fprintf(writer, "%s %s", port, request.URL.RequestURI())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
http.ListenAndServe("localhost:"+port, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordFetch(url, logPath string) {
|
||||||
|
line := ""
|
||||||
|
response, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
line = fmt.Sprintf("%s -> error %v\n", url, err)
|
||||||
|
} else {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
line = fmt.Sprintf("%s -> %d %s\n", url, response.StatusCode, body)
|
||||||
|
}
|
||||||
|
logFile, err := os.OpenFile(
|
||||||
|
logPath,
|
||||||
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logFile.WriteString(line)
|
||||||
|
logFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubBun answers install, fails to build impl-02, and serves the preview from
|
||||||
|
// the test binary on the port it was given.
|
||||||
|
const stubBun = `#!/bin/sh
|
||||||
|
echo "$PWD $*" >> "%[1]s"
|
||||||
|
if [ "$1" = "run" ] && [ "$2" = "build" ]; then
|
||||||
|
case "$PWD" in *impl-02) echo "TS2322: type error" >&2; exit 1 ;; esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$1" = "run" ] && [ "$2" = "preview" ]; then
|
||||||
|
port=""
|
||||||
|
previous=""
|
||||||
|
for argument in "$@"; do
|
||||||
|
if [ "$previous" = "--port" ]; then port="$argument"; fi
|
||||||
|
previous="$argument"
|
||||||
|
done
|
||||||
|
SWEEP_TEST_SERVE_PORT="$port" exec "%[2]s"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
|
||||||
|
// stubCampaign records the argv it was handed and whether the sweep manifest
|
||||||
|
// was already on disk when it ran, fetches the URL it was told to drive, writes
|
||||||
|
// the campaign directory the real tool would write, and fails impl-03 seed 4.
|
||||||
|
const stubCampaign = `#!/bin/sh
|
||||||
|
output=""
|
||||||
|
seed=""
|
||||||
|
url=""
|
||||||
|
previous=""
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$previous" in
|
||||||
|
--output) output="$argument" ;;
|
||||||
|
--seeds) seed="$argument" ;;
|
||||||
|
--bundle-id) url="$argument" ;;
|
||||||
|
esac
|
||||||
|
previous="$argument"
|
||||||
|
done
|
||||||
|
manifest=missing
|
||||||
|
if [ -f "%[1]s" ]; then manifest=present; fi
|
||||||
|
echo "manifest=$manifest argv: $*" >> "%[2]s"
|
||||||
|
SWEEP_TEST_FETCH_URL="$url" SWEEP_TEST_FETCH_LOG="%[3]s" "%[4]s"
|
||||||
|
mkdir -p "$output"
|
||||||
|
printf '{"arm":"stub","seeds":[%%s]}\n' "$seed" > "$output/campaign.json"
|
||||||
|
echo "stub campaign seed=$seed url=$url"
|
||||||
|
case "$output" in
|
||||||
|
*impl-03/seed-4) exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestRun_EndToEndAgainstStubBunAndCampaign(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
implementations := filepath.Join(root, "implementations")
|
||||||
|
for _, name := range []string{"impl-01", "impl-02", "impl-03"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(implementations, name), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
specPath := filepath.Join(root, "relay.ts")
|
||||||
|
if err := os.WriteFile(specPath, []byte("export const properties = [];\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output := filepath.Join(root, "campaigns")
|
||||||
|
bunLog := filepath.Join(root, "bun.log")
|
||||||
|
campaignLog := filepath.Join(root, "campaign.log")
|
||||||
|
fetchLog := filepath.Join(root, "fetch.log")
|
||||||
|
testBinary, err := filepath.Abs(os.Args[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bunPath := writeScript(
|
||||||
|
t,
|
||||||
|
filepath.Join(root, "stub-bun"),
|
||||||
|
fmt.Sprintf(stubBun, bunLog, testBinary),
|
||||||
|
)
|
||||||
|
campaignPath := writeScript(
|
||||||
|
t,
|
||||||
|
filepath.Join(root, "stub-campaign"),
|
||||||
|
fmt.Sprintf(
|
||||||
|
stubCampaign,
|
||||||
|
filepath.Join(output, manifestFileName),
|
||||||
|
campaignLog,
|
||||||
|
fetchLog,
|
||||||
|
testBinary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sanderlingPath := writeScript(
|
||||||
|
t,
|
||||||
|
filepath.Join(root, "stub-sanderling"),
|
||||||
|
"#!/bin/sh\nexit 0\n",
|
||||||
|
)
|
||||||
|
basePort := freePortRange(t, 3)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
err = run([]string{
|
||||||
|
"--implementations", implementations,
|
||||||
|
"--spec", specPath,
|
||||||
|
"--seeds", "4-5",
|
||||||
|
"--max-steps", "40",
|
||||||
|
"--duration", "30s",
|
||||||
|
"--concurrency", "2",
|
||||||
|
"--base-port", fmt.Sprint(basePort),
|
||||||
|
"--output", output,
|
||||||
|
"--bun", bunPath,
|
||||||
|
"--campaign", campaignPath,
|
||||||
|
"--sanderling", sanderlingPath,
|
||||||
|
}, &stdout, io.Discard)
|
||||||
|
if err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "1 of 3 implementations never ran") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected the failed build and the failed campaign to be reported, got %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var recorded manifest
|
||||||
|
manifestBody, err := os.ReadFile(filepath.Join(output, manifestFileName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(manifestBody, &recorded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(recorded.Seeds, []int64{4, 5}) {
|
||||||
|
t.Errorf("intended seeds: got %v", recorded.Seeds)
|
||||||
|
}
|
||||||
|
if len(recorded.Implementations) != 3 {
|
||||||
|
t.Fatalf("intended implementations: got %v", recorded.Implementations)
|
||||||
|
}
|
||||||
|
for index, planned := range recorded.Implementations {
|
||||||
|
wantPort := basePort + index
|
||||||
|
if planned.Port != wantPort {
|
||||||
|
t.Errorf(
|
||||||
|
"%s port: got %d, want %d",
|
||||||
|
planned.Name,
|
||||||
|
planned.Port,
|
||||||
|
wantPort,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
wantURL := fmt.Sprintf("http://localhost:%d/?seed={seed}", wantPort)
|
||||||
|
if planned.URLTemplate != wantURL {
|
||||||
|
t.Errorf(
|
||||||
|
"%s url template: got %q, want %q",
|
||||||
|
planned.Name,
|
||||||
|
planned.URLTemplate,
|
||||||
|
wantURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if recorded.Generator != "seeded" || recorded.MaxSteps != 40 {
|
||||||
|
t.Errorf(
|
||||||
|
"manifest generator/budget: got %q/%d",
|
||||||
|
recorded.Generator,
|
||||||
|
recorded.MaxSteps,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// impl-02 fails its build, so the two implementations either side of it
|
||||||
|
// still have to reach the campaign tool with both seeds.
|
||||||
|
campaignLines := readLines(t, campaignLog)
|
||||||
|
if len(campaignLines) != 4 {
|
||||||
|
t.Fatalf(
|
||||||
|
"campaign invocations: got %d, want 4:\n%s",
|
||||||
|
len(campaignLines),
|
||||||
|
strings.Join(campaignLines, "\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range campaignLines {
|
||||||
|
if !strings.HasPrefix(line, "manifest=present") {
|
||||||
|
t.Errorf(
|
||||||
|
"a campaign ran before the sweep manifest was written: %q",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
arguments := strings.Fields(strings.SplitN(line, "argv: ", 2)[1])
|
||||||
|
arm := argumentValue(arguments, "--arm")
|
||||||
|
seed := argumentValue(arguments, "--seeds")
|
||||||
|
bundle := argumentValue(arguments, "--bundle-id")
|
||||||
|
port := basePort + slices.Index(
|
||||||
|
[]string{"impl-01", "impl-02", "impl-03"},
|
||||||
|
arm,
|
||||||
|
)
|
||||||
|
wantBundle := fmt.Sprintf("http://localhost:%d/?seed=%s", port, seed)
|
||||||
|
if bundle != wantBundle {
|
||||||
|
t.Errorf(
|
||||||
|
"%s seed %s: bundle id %q, want %q",
|
||||||
|
arm,
|
||||||
|
seed,
|
||||||
|
bundle,
|
||||||
|
wantBundle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if got := argumentValue(arguments, "--output"); got != filepath.Join(
|
||||||
|
output,
|
||||||
|
arm,
|
||||||
|
"seed-"+seed,
|
||||||
|
) {
|
||||||
|
t.Errorf("%s seed %s: campaign output %q", arm, seed, got)
|
||||||
|
}
|
||||||
|
if got := argumentValue(arguments, "--sanderling"); got != sanderlingPath {
|
||||||
|
t.Errorf("%s seed %s: sanderling path %q", arm, seed, got)
|
||||||
|
}
|
||||||
|
seen[arm+"/"+seed] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"impl-01/4", "impl-01/5", "impl-03/4", "impl-03/5"} {
|
||||||
|
if !seen[want] {
|
||||||
|
t.Errorf("%s never reached the campaign tool", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the served page actually saw: the right port for the
|
||||||
|
// implementation, carrying the same seed the campaign was given.
|
||||||
|
fetched := readLines(t, fetchLog)
|
||||||
|
for _, want := range []string{
|
||||||
|
fmt.Sprintf("http://localhost:%d/?seed=4 -> 200 %d /?seed=4", basePort, basePort),
|
||||||
|
fmt.Sprintf("http://localhost:%d/?seed=5 -> 200 %d /?seed=5", basePort, basePort),
|
||||||
|
fmt.Sprintf("http://localhost:%d/?seed=4 -> 200 %d /?seed=4", basePort+2, basePort+2),
|
||||||
|
fmt.Sprintf("http://localhost:%d/?seed=5 -> 200 %d /?seed=5", basePort+2, basePort+2),
|
||||||
|
} {
|
||||||
|
if !slices.Contains(fetched, want) {
|
||||||
|
t.Errorf(
|
||||||
|
"the served page never saw %q:\n%s",
|
||||||
|
want,
|
||||||
|
strings.Join(fetched, "\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records := readRecords(t, filepath.Join(output, recordsFileName))
|
||||||
|
if len(records) != 3 {
|
||||||
|
t.Fatalf("implementation records: got %d, want 3", len(records))
|
||||||
|
}
|
||||||
|
byName := map[string]implementationRecord{}
|
||||||
|
for _, record := range records {
|
||||||
|
byName[record.Name] = record
|
||||||
|
}
|
||||||
|
failed := byName["impl-02"]
|
||||||
|
if failed.FailedStage != stageBuild || len(failed.Runs) != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"impl-02: got stage %q with %d runs, want a build failure and no runs",
|
||||||
|
failed.FailedStage,
|
||||||
|
len(failed.Runs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !strings.Contains(failed.Error, "build.log") {
|
||||||
|
t.Errorf("impl-02 error should point at its log: %q", failed.Error)
|
||||||
|
}
|
||||||
|
buildLog, err := os.ReadFile(
|
||||||
|
filepath.Join(output, "impl-02", stageBuild+".log"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(buildLog), "TS2322") {
|
||||||
|
t.Errorf("impl-02 build log lost the compiler error: %q", buildLog)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"impl-01", "impl-03"} {
|
||||||
|
record := byName[name]
|
||||||
|
if record.FailedStage != "" || len(record.Runs) != 2 {
|
||||||
|
t.Errorf(
|
||||||
|
"%s: stage %q with %d runs, want no failure and 2 runs",
|
||||||
|
name,
|
||||||
|
record.FailedStage,
|
||||||
|
len(record.Runs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if record.MonotonicMillis <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"%s took %d ms, so nothing timed how long it worked",
|
||||||
|
name,
|
||||||
|
record.MonotonicMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, run := range record.Runs {
|
||||||
|
if run.MonotonicMillis <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"%s seed %d took %d ms, so nothing timed the campaign",
|
||||||
|
name,
|
||||||
|
run.Seed,
|
||||||
|
run.MonotonicMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exit := byName["impl-03"].Runs[0].ExitCode; exit != 1 {
|
||||||
|
t.Errorf("impl-03 seed 4 exit code: got %d, want 1", exit)
|
||||||
|
}
|
||||||
|
if exit := byName["impl-03"].Runs[1].ExitCode; exit != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"impl-03 seed 5 ran after seed 4 failed and should have exited 0, got %d",
|
||||||
|
exit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"impl-01", "impl-03"} {
|
||||||
|
for _, seed := range []string{"4", "5"} {
|
||||||
|
directory := filepath.Join(output, name, "seed-"+seed)
|
||||||
|
if _, err := os.Stat(filepath.Join(directory, "campaign.json")); err != nil {
|
||||||
|
t.Errorf(
|
||||||
|
"%s seed %s: no campaign directory: %v",
|
||||||
|
name,
|
||||||
|
seed,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(directory, "campaign.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(log), "stub campaign seed="+seed) {
|
||||||
|
t.Errorf(
|
||||||
|
"%s seed %s: campaign output was not captured: %q",
|
||||||
|
name,
|
||||||
|
seed,
|
||||||
|
log,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installed := readLines(t, bunLog)
|
||||||
|
for _, name := range []string{"impl-01", "impl-02", "impl-03"} {
|
||||||
|
if !slices.Contains(
|
||||||
|
installed,
|
||||||
|
filepath.Join(implementations, name)+" install",
|
||||||
|
) {
|
||||||
|
t.Errorf(
|
||||||
|
"%s was never installed:\n%s",
|
||||||
|
name,
|
||||||
|
strings.Join(installed, "\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every preview server the sweep started is gone with it: a leaked one
|
||||||
|
// holds its port, and the next sweep would be served by the old build.
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
for _, port := range []int{basePort, basePort + 2} {
|
||||||
|
if response, err := client.Get(readinessURL(port)); err == nil {
|
||||||
|
response.Body.Close()
|
||||||
|
t.Errorf("port %d is still served after the sweep finished", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "failed at build") {
|
||||||
|
t.Errorf(
|
||||||
|
"progress output does not name the build failure: %q",
|
||||||
|
stdout.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeScript(t *testing.T, path, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLines(t *testing.T, path string) []string {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(body)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
if line := strings.TrimSpace(scanner.Text()); line != "" {
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRecords(t *testing.T, path string) []implementationRecord {
|
||||||
|
t.Helper()
|
||||||
|
var records []implementationRecord
|
||||||
|
for _, line := range readLines(t, path) {
|
||||||
|
var record implementationRecord
|
||||||
|
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||||
|
t.Fatalf("%s: %v", line, err)
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// freePortRange finds count consecutive free ports, which is what the sweep
|
||||||
|
// hands out: one port per implementation from --base-port upwards.
|
||||||
|
func freePortRange(t *testing.T, count int) int {
|
||||||
|
t.Helper()
|
||||||
|
for range 100 {
|
||||||
|
base := 20000 + rand.Intn(20000)
|
||||||
|
if portsAreFree(base, count) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no run of %d free ports", count)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func portsAreFree(base, count int) bool {
|
||||||
|
for offset := range count {
|
||||||
|
listener, err := net.Listen(
|
||||||
|
"tcp",
|
||||||
|
fmt.Sprintf("localhost:%d", base+offset),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// Command implementation-sweep runs one identical campaign against every model
|
||||||
|
// implementation of a single requirement. It installs, builds and serves each
|
||||||
|
// implementation on its own port, then hands the campaign tool the same seed
|
||||||
|
// slice, the same step budget and the same generator for all of them, so a
|
||||||
|
// difference between implementations is not a difference in exploration.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/seedspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The generator and the platform are fixed rather than exposed: the
|
||||||
|
// pre-registration runs the seeded policy against a served web build, and a
|
||||||
|
// sweep that could quietly run something else records a comparison nobody made.
|
||||||
|
const (
|
||||||
|
generator = "seeded"
|
||||||
|
platform = "web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultConcurrency is how many implementations are built, served and swept at
|
||||||
|
// once. fleet.md measured eight concurrent web campaigns clean at about 1.1 GB
|
||||||
|
// resident each, parallel efficiency 0.83 at eight against 0.87 at six, and a
|
||||||
|
// knee at twelve to sixteen, on a contended laptop it says to re-measure before
|
||||||
|
// trusting anything above eight. Six sits at the better efficiency, costs about
|
||||||
|
// 7 GB of a 64 GB host, and leaves the Android emulator farm that shares that
|
||||||
|
// host its four to six slots. Each worker here also carries a vite server the
|
||||||
|
// fleet measurement did not include.
|
||||||
|
const defaultConcurrency = 6
|
||||||
|
|
||||||
|
// defaultBasePort is the first port handed out. Vite's own defaults are 5173
|
||||||
|
// and 4173, so a sweep starting here does not collide with a dev server someone
|
||||||
|
// left running.
|
||||||
|
const defaultBasePort = 5300
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
implementationsDirectory string
|
||||||
|
specPath string
|
||||||
|
outputDirectory string
|
||||||
|
seeds []int64
|
||||||
|
maxSteps int
|
||||||
|
duration time.Duration
|
||||||
|
concurrency int
|
||||||
|
basePort int
|
||||||
|
bunPath string
|
||||||
|
campaignPath string
|
||||||
|
sanderlingPath string
|
||||||
|
extraArguments []string
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = `implementation-sweep runs one seeded campaign per model implementation.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
implementation-sweep --implementations <dir> --spec <path> --seeds <spec>
|
||||||
|
--max-steps <n> --output <dir> [flags]
|
||||||
|
[-- <sanderling test flags>]
|
||||||
|
|
||||||
|
Each impl-* directory under --implementations is installed, built and served on
|
||||||
|
its own port, and every one is swept with the same seeds and the same step
|
||||||
|
budget. An implementation that fails to install, build or serve is recorded and
|
||||||
|
the sweep moves on to the next one.
|
||||||
|
|
||||||
|
Everything after a bare -- reaches every sanderling test call through the
|
||||||
|
campaign tool.
|
||||||
|
`
|
||||||
|
|
||||||
|
func parseArguments(arguments []string, stderr io.Writer) (config, error) {
|
||||||
|
flagSet := flag.NewFlagSet("implementation-sweep", flag.ContinueOnError)
|
||||||
|
flagSet.SetOutput(stderr)
|
||||||
|
flagSet.Usage = func() {
|
||||||
|
fmt.Fprint(stderr, usage)
|
||||||
|
flagSet.PrintDefaults()
|
||||||
|
}
|
||||||
|
var configuration config
|
||||||
|
var seedSpecification string
|
||||||
|
flagSet.StringVar(
|
||||||
|
&configuration.implementationsDirectory,
|
||||||
|
"implementations",
|
||||||
|
"",
|
||||||
|
"directory holding impl-01 to impl-NN (required)",
|
||||||
|
)
|
||||||
|
flagSet.StringVar(
|
||||||
|
&configuration.specPath,
|
||||||
|
"spec",
|
||||||
|
"",
|
||||||
|
"path to the property set every implementation is run against (required)",
|
||||||
|
)
|
||||||
|
flagSet.StringVar(
|
||||||
|
&seedSpecification,
|
||||||
|
"seeds",
|
||||||
|
"",
|
||||||
|
"seeds every implementation runs: ranges and lists, e.g. 1-10,20 (required)",
|
||||||
|
)
|
||||||
|
flagSet.IntVar(
|
||||||
|
&configuration.maxSteps,
|
||||||
|
"max-steps",
|
||||||
|
0,
|
||||||
|
"per-run step budget, identical across implementations (required, must be positive)",
|
||||||
|
)
|
||||||
|
flagSet.DurationVar(
|
||||||
|
&configuration.duration,
|
||||||
|
"duration",
|
||||||
|
5*time.Minute,
|
||||||
|
"per-run wall-clock ceiling passed to each campaign",
|
||||||
|
)
|
||||||
|
flagSet.IntVar(
|
||||||
|
&configuration.concurrency,
|
||||||
|
"concurrency",
|
||||||
|
defaultConcurrency,
|
||||||
|
"implementations built, served and swept at once",
|
||||||
|
)
|
||||||
|
flagSet.IntVar(
|
||||||
|
&configuration.basePort,
|
||||||
|
"base-port",
|
||||||
|
defaultBasePort,
|
||||||
|
"first port served; each implementation takes the next one in name order",
|
||||||
|
)
|
||||||
|
flagSet.StringVar(
|
||||||
|
&configuration.outputDirectory,
|
||||||
|
"output",
|
||||||
|
"",
|
||||||
|
"campaign tree to create (required)",
|
||||||
|
)
|
||||||
|
flagSet.StringVar(
|
||||||
|
&configuration.bunPath,
|
||||||
|
"bun",
|
||||||
|
"bun",
|
||||||
|
"bun binary that installs, builds and serves each implementation",
|
||||||
|
)
|
||||||
|
flagSet.StringVar(
|
||||||
|
&configuration.campaignPath,
|
||||||
|
"campaign",
|
||||||
|
"campaign",
|
||||||
|
"campaign binary to invoke per implementation and seed",
|
||||||
|
)
|
||||||
|
flagSet.StringVar(
|
||||||
|
&configuration.sanderlingPath,
|
||||||
|
"sanderling",
|
||||||
|
"sanderling",
|
||||||
|
"sanderling binary each campaign invokes",
|
||||||
|
)
|
||||||
|
if err := flagSet.Parse(arguments); err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
configuration.extraArguments = flagSet.Args()
|
||||||
|
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"--implementations": configuration.implementationsDirectory,
|
||||||
|
"--spec": configuration.specPath,
|
||||||
|
"--seeds": seedSpecification,
|
||||||
|
"--output": configuration.outputDirectory,
|
||||||
|
} {
|
||||||
|
if value == "" {
|
||||||
|
return config{}, fmt.Errorf("%s is required", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if configuration.maxSteps <= 0 {
|
||||||
|
return config{}, fmt.Errorf(
|
||||||
|
"--max-steps must be positive: every implementation needs the same step budget",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if configuration.duration <= 0 {
|
||||||
|
return config{}, fmt.Errorf(
|
||||||
|
"--duration must be positive: %s",
|
||||||
|
configuration.duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if configuration.concurrency <= 0 {
|
||||||
|
return config{}, fmt.Errorf(
|
||||||
|
"--concurrency must be positive: %d",
|
||||||
|
configuration.concurrency,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if configuration.basePort < 1024 || configuration.basePort > 65535 {
|
||||||
|
return config{}, fmt.Errorf(
|
||||||
|
"--base-port %d is outside 1024-65535",
|
||||||
|
configuration.basePort,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seeds, err := seedspec.Parse(seedSpecification)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, fmt.Errorf("--seeds: %w", err)
|
||||||
|
}
|
||||||
|
configuration.seeds = seeds
|
||||||
|
for name, value := range map[string]*string{
|
||||||
|
"--implementations": &configuration.implementationsDirectory,
|
||||||
|
"--spec": &configuration.specPath,
|
||||||
|
"--output": &configuration.outputDirectory,
|
||||||
|
} {
|
||||||
|
absolute, err := filepath.Abs(*value)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*value = absolute
|
||||||
|
}
|
||||||
|
return configuration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// servedURL is the one place a seed becomes a URL. The same seed is also handed
|
||||||
|
// to the campaign as --seeds, which reaches sanderling as --seed and fixes the
|
||||||
|
// exploration, while the scaffold reads ?seed= and fixes the latency and the
|
||||||
|
// outcome of every send. A violation replays only when both carry the same
|
||||||
|
// number, so both come from the seed argument here and never from two flags.
|
||||||
|
func servedURL(port int, seed string) string {
|
||||||
|
return fmt.Sprintf("http://localhost:%d/?seed=%s", port, seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readinessURL(port int) string {
|
||||||
|
return fmt.Sprintf("http://localhost:%d/", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func campaignDirectory(
|
||||||
|
configuration config,
|
||||||
|
target implementation,
|
||||||
|
seed string,
|
||||||
|
) string {
|
||||||
|
return filepath.Join(
|
||||||
|
configuration.outputDirectory,
|
||||||
|
target.Name,
|
||||||
|
"seed-"+seed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// campaignArguments builds one campaign invocation. The seed is a string
|
||||||
|
// because it lands in two arguments, --seeds and the ?seed= of --bundle-id,
|
||||||
|
// and passing it once keeps them from drifting apart.
|
||||||
|
func campaignArguments(
|
||||||
|
configuration config,
|
||||||
|
target implementation,
|
||||||
|
seed string,
|
||||||
|
) []string {
|
||||||
|
arguments := []string{
|
||||||
|
"--spec", configuration.specPath,
|
||||||
|
"--bundle-id", servedURL(target.Port, seed),
|
||||||
|
"--platform", platform,
|
||||||
|
"--arm", target.Name,
|
||||||
|
"--generator", generator,
|
||||||
|
"--max-steps", strconv.Itoa(configuration.maxSteps),
|
||||||
|
"--duration", configuration.duration.String(),
|
||||||
|
"--seeds", seed,
|
||||||
|
"--sanderling", configuration.sanderlingPath,
|
||||||
|
"--output", campaignDirectory(configuration, target, seed),
|
||||||
|
}
|
||||||
|
if len(configuration.extraArguments) > 0 {
|
||||||
|
arguments = append(arguments, "--")
|
||||||
|
arguments = append(arguments, configuration.extraArguments...)
|
||||||
|
}
|
||||||
|
return arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(arguments []string, stdout, stderr io.Writer) error {
|
||||||
|
configuration, err := parseArguments(arguments, stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := signal.NotifyContext(
|
||||||
|
context.Background(),
|
||||||
|
os.Interrupt,
|
||||||
|
syscall.SIGTERM,
|
||||||
|
)
|
||||||
|
defer cancel()
|
||||||
|
return runSweep(ctx, configuration, stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func baseArguments() []string {
|
||||||
|
return []string{
|
||||||
|
"--implementations", "/e4/implementations",
|
||||||
|
"--spec", "/e4/relay.ts",
|
||||||
|
"--seeds", "1-3",
|
||||||
|
"--max-steps", "400",
|
||||||
|
"--output", "/campaigns/e4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArguments_DefaultsAndSeeds(t *testing.T) {
|
||||||
|
configuration, err := parseArguments(baseArguments(), io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(configuration.seeds, []int64{1, 2, 3}) {
|
||||||
|
t.Errorf("seeds: got %v", configuration.seeds)
|
||||||
|
}
|
||||||
|
if configuration.concurrency != defaultConcurrency {
|
||||||
|
t.Errorf(
|
||||||
|
"concurrency default: got %d, want %d",
|
||||||
|
configuration.concurrency,
|
||||||
|
defaultConcurrency,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if configuration.basePort != defaultBasePort {
|
||||||
|
t.Errorf(
|
||||||
|
"base port default: got %d, want %d",
|
||||||
|
configuration.basePort,
|
||||||
|
defaultBasePort,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if configuration.duration != 5*time.Minute {
|
||||||
|
t.Errorf("duration default: got %s", configuration.duration)
|
||||||
|
}
|
||||||
|
for name, got := range map[string]string{
|
||||||
|
"bun": configuration.bunPath,
|
||||||
|
"campaign": configuration.campaignPath,
|
||||||
|
"sanderling": configuration.sanderlingPath,
|
||||||
|
} {
|
||||||
|
if got != name {
|
||||||
|
t.Errorf("%s path default: got %q", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArguments_Rejections(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
arguments []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"missing implementations",
|
||||||
|
[]string{
|
||||||
|
"--spec",
|
||||||
|
"s",
|
||||||
|
"--seeds",
|
||||||
|
"1",
|
||||||
|
"--max-steps",
|
||||||
|
"10",
|
||||||
|
"--output",
|
||||||
|
"o",
|
||||||
|
},
|
||||||
|
"--implementations is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"missing spec",
|
||||||
|
[]string{
|
||||||
|
"--implementations",
|
||||||
|
"i",
|
||||||
|
"--seeds",
|
||||||
|
"1",
|
||||||
|
"--max-steps",
|
||||||
|
"10",
|
||||||
|
"--output",
|
||||||
|
"o",
|
||||||
|
},
|
||||||
|
"--spec is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"missing output",
|
||||||
|
[]string{
|
||||||
|
"--implementations",
|
||||||
|
"i",
|
||||||
|
"--spec",
|
||||||
|
"s",
|
||||||
|
"--seeds",
|
||||||
|
"1",
|
||||||
|
"--max-steps",
|
||||||
|
"10",
|
||||||
|
},
|
||||||
|
"--output is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zero max steps",
|
||||||
|
append(baseArguments(), "--max-steps", "0"),
|
||||||
|
"--max-steps must be positive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zero concurrency",
|
||||||
|
append(baseArguments(), "--concurrency", "0"),
|
||||||
|
"--concurrency must be positive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"privileged base port",
|
||||||
|
append(baseArguments(), "--base-port", "80"),
|
||||||
|
"outside 1024-65535",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed zero",
|
||||||
|
append(baseArguments(), "--seeds", "0,1"),
|
||||||
|
"not reproducible",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
_, err := parseArguments(testCase.arguments, io.Discard)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s: expected error", testCase.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), testCase.want) {
|
||||||
|
t.Errorf(
|
||||||
|
"%s: got %q, want it to contain %q",
|
||||||
|
testCase.name,
|
||||||
|
err,
|
||||||
|
testCase.want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seed reaches two independent things, the campaign's own seed and the
|
||||||
|
// scaffold's failure stream, and a replay reproduces neither unless they carry
|
||||||
|
// the same number.
|
||||||
|
func TestCampaignArguments_OneSeedReachesBothTheCampaignAndTheURL(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
configuration, err := parseArguments(
|
||||||
|
append(baseArguments(), "--", "--clear-data=false"),
|
||||||
|
io.Discard,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := implementation{
|
||||||
|
Name: "impl-07",
|
||||||
|
Directory: "/e4/implementations/impl-07",
|
||||||
|
Port: 5306,
|
||||||
|
}
|
||||||
|
for _, seed := range []string{"1", "42"} {
|
||||||
|
arguments := campaignArguments(configuration, target, seed)
|
||||||
|
if got := argumentValue(arguments, "--seeds"); got != seed {
|
||||||
|
t.Errorf("--seeds: got %q, want %q", got, seed)
|
||||||
|
}
|
||||||
|
bundle := argumentValue(arguments, "--bundle-id")
|
||||||
|
parsed, err := url.Parse(bundle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("--bundle-id %q: %v", bundle, err)
|
||||||
|
}
|
||||||
|
if got := parsed.Query().Get("seed"); got != seed {
|
||||||
|
t.Errorf(
|
||||||
|
"served URL seed: got %q, want %q (from %q)",
|
||||||
|
got,
|
||||||
|
seed,
|
||||||
|
bundle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if parsed.Host != "localhost:"+strconv.Itoa(target.Port) {
|
||||||
|
t.Errorf(
|
||||||
|
"served host: got %q, want the implementation's own port %d",
|
||||||
|
parsed.Host,
|
||||||
|
target.Port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for flagName, want := range map[string]string{
|
||||||
|
"--arm": "impl-07",
|
||||||
|
"--platform": "web",
|
||||||
|
"--generator": "seeded",
|
||||||
|
"--max-steps": "400",
|
||||||
|
"--spec": "/e4/relay.ts",
|
||||||
|
"--output": filepath.Join("/campaigns/e4", "impl-07", "seed-"+seed),
|
||||||
|
} {
|
||||||
|
if got := argumentValue(arguments, flagName); got != want {
|
||||||
|
t.Errorf("%s: got %q, want %q", flagName, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if arguments[len(arguments)-2] != "--" ||
|
||||||
|
arguments[len(arguments)-1] != "--clear-data=false" {
|
||||||
|
t.Errorf("passthrough flags lost: %v", arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func argumentValue(arguments []string, name string) string {
|
||||||
|
index := slices.Index(arguments, name)
|
||||||
|
if index < 0 || index+1 >= len(arguments) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return arguments[index+1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
manifestFileName = "sweep.json"
|
||||||
|
recordsFileName = "implementations.jsonl"
|
||||||
|
seedPlaceholder = "{seed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
// plannedImplementation is one implementation the sweep intends to run, with
|
||||||
|
// the port it is served on and the URL every seed is served at.
|
||||||
|
type plannedImplementation struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Directory string `json:"directory"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
URLTemplate string `json:"url_template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifest is sweep.json: what the sweep INTENDED to run, written before the
|
||||||
|
// first install so a host that dropped an implementation or a seed shows up as
|
||||||
|
// a missing run rather than as a smaller sample.
|
||||||
|
type manifest struct {
|
||||||
|
Generator string `json:"generator"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
SpecPath string `json:"spec_path"`
|
||||||
|
MaxSteps int `json:"max_steps"`
|
||||||
|
DurationMillis int64 `json:"duration_millis"`
|
||||||
|
Seeds []int64 `json:"seeds"`
|
||||||
|
Implementations []plannedImplementation `json:"implementations"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
BunPath string `json:"bun_path"`
|
||||||
|
CampaignPath string `json:"campaign_path"`
|
||||||
|
SanderlingPath string `json:"sanderling_path"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildManifest(
|
||||||
|
configuration config,
|
||||||
|
implementations []implementation,
|
||||||
|
host string,
|
||||||
|
startedAt time.Time,
|
||||||
|
) manifest {
|
||||||
|
planned := make([]plannedImplementation, 0, len(implementations))
|
||||||
|
for _, target := range implementations {
|
||||||
|
planned = append(planned, plannedImplementation{
|
||||||
|
Name: target.Name,
|
||||||
|
Directory: target.Directory,
|
||||||
|
Port: target.Port,
|
||||||
|
URLTemplate: servedURL(target.Port, seedPlaceholder),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return manifest{
|
||||||
|
Generator: generator,
|
||||||
|
Platform: platform,
|
||||||
|
SpecPath: configuration.specPath,
|
||||||
|
MaxSteps: configuration.maxSteps,
|
||||||
|
DurationMillis: configuration.duration.Milliseconds(),
|
||||||
|
Seeds: configuration.seeds,
|
||||||
|
Implementations: planned,
|
||||||
|
Concurrency: configuration.concurrency,
|
||||||
|
Host: host,
|
||||||
|
BunPath: configuration.bunPath,
|
||||||
|
CampaignPath: configuration.campaignPath,
|
||||||
|
SanderlingPath: configuration.sanderlingPath,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeManifest(directory string, value manifest) error {
|
||||||
|
body, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal manifest: %w", err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(
|
||||||
|
filepath.Join(directory, manifestFileName),
|
||||||
|
append(body, '\n'),
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRecord is one campaign, which is one implementation at one seed.
|
||||||
|
type runRecord struct {
|
||||||
|
Seed int64 `json:"seed"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
LaunchError string `json:"launch_error,omitempty"`
|
||||||
|
CampaignDirectory string `json:"campaign_directory"`
|
||||||
|
MonotonicMillis int64 `json:"monotonic_millis"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// implementationRecord is one line of implementations.jsonl. FailedStage names
|
||||||
|
// the step that stopped this implementation, and an implementation that never
|
||||||
|
// got past install, build or serve carries no runs at all.
|
||||||
|
type implementationRecord struct {
|
||||||
|
Name string `json:"implementation"`
|
||||||
|
Directory string `json:"directory"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
FailedStage string `json:"failed_stage,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
MonotonicMillis int64 `json:"monotonic_millis"`
|
||||||
|
Runs []runRecord `json:"runs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
stageInstall = "install"
|
||||||
|
stageBuild = "build"
|
||||||
|
stageServe = "serve"
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// serverStartTimeout covers a cold vite start on a host already running
|
||||||
|
// five other implementations.
|
||||||
|
serverStartTimeout = 90 * time.Second
|
||||||
|
serverPollInterval = 250 * time.Millisecond
|
||||||
|
serverShutdownGrace = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// server is one implementation's preview server. It runs in its own process
|
||||||
|
// group so that stopping it takes the whole vite tree with it: a leaked server
|
||||||
|
// holds its port, and the next sweep against that implementation would be
|
||||||
|
// served by the previous build.
|
||||||
|
type server struct {
|
||||||
|
command *exec.Cmd
|
||||||
|
logFile *os.File
|
||||||
|
exited chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServer(
|
||||||
|
ctx context.Context,
|
||||||
|
configuration config,
|
||||||
|
target implementation,
|
||||||
|
logPath string,
|
||||||
|
) (*server, error) {
|
||||||
|
logFile, err := os.Create(logPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
command := exec.CommandContext(ctx, configuration.bunPath,
|
||||||
|
"run", "preview", "--port", fmt.Sprint(target.Port), "--strictPort")
|
||||||
|
command.Dir = target.Directory
|
||||||
|
command.Stdout = logFile
|
||||||
|
command.Stderr = logFile
|
||||||
|
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
if err := command.Start(); err != nil {
|
||||||
|
logFile.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
running := &server{
|
||||||
|
command: command,
|
||||||
|
logFile: logFile,
|
||||||
|
exited: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
command.Wait()
|
||||||
|
close(running.exited)
|
||||||
|
}()
|
||||||
|
return running, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitReady polls the served page until it answers. A server that exits first
|
||||||
|
// is reported as such rather than waited on for the full timeout, because the
|
||||||
|
// usual cause is a port already taken and that answer is in the log.
|
||||||
|
func (s *server) waitReady(ctx context.Context, url string) error {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
deadline := time.Now().Add(serverStartTimeout)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.exited:
|
||||||
|
return fmt.Errorf("server exited before it answered %s", url)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
response, err := client.Get(url)
|
||||||
|
if err == nil {
|
||||||
|
io.Copy(io.Discard, response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"server did not answer %s within %s",
|
||||||
|
url,
|
||||||
|
serverStartTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
time.Sleep(serverPollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) stop() {
|
||||||
|
if s.command.Process != nil {
|
||||||
|
group := -s.command.Process.Pid
|
||||||
|
syscall.Kill(group, syscall.SIGTERM)
|
||||||
|
select {
|
||||||
|
case <-s.exited:
|
||||||
|
case <-time.After(serverShutdownGrace):
|
||||||
|
syscall.Kill(group, syscall.SIGKILL)
|
||||||
|
<-s.exited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.logFile.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bunSpawningAServer serves from a child process and then waits, which is the
|
||||||
|
// shape of `bun run preview`: the port belongs to something below the process
|
||||||
|
// the sweep started.
|
||||||
|
const bunSpawningAServer = `#!/bin/sh
|
||||||
|
port=""
|
||||||
|
previous=""
|
||||||
|
for argument in "$@"; do
|
||||||
|
if [ "$previous" = "--port" ]; then port="$argument"; fi
|
||||||
|
previous="$argument"
|
||||||
|
done
|
||||||
|
SWEEP_TEST_SERVE_PORT="$port" "%[1]s" &
|
||||||
|
wait
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestServerStop_TakesTheProcessBelowItWithTheServer(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
testBinary, err := filepath.Abs(os.Args[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bunPath := writeScript(
|
||||||
|
t,
|
||||||
|
filepath.Join(directory, "stub-bun"),
|
||||||
|
fmt.Sprintf(bunSpawningAServer, testBinary),
|
||||||
|
)
|
||||||
|
port := freePortRange(t, 1)
|
||||||
|
target := implementation{Name: "impl-01", Directory: directory, Port: port}
|
||||||
|
|
||||||
|
// Background rather than the test context: only stop() may end this
|
||||||
|
// server, or a leak would be hidden by the context being cancelled.
|
||||||
|
running, err := startServer(
|
||||||
|
context.Background(),
|
||||||
|
config{bunPath: bunPath},
|
||||||
|
target,
|
||||||
|
filepath.Join(directory, "serve.log"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(
|
||||||
|
func() { syscall.Kill(-running.command.Process.Pid, syscall.SIGKILL) },
|
||||||
|
)
|
||||||
|
if err := running.waitReady(context.Background(), readinessURL(port)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
running.stop()
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Second}
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
response, err := client.Get(readinessURL(port))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf(
|
||||||
|
"port %d is still served after stop(): the server below bun outlived the sweep and holds the port",
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerWaitReady_ReportsAServerThatExited(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
bunPath := writeScript(
|
||||||
|
t,
|
||||||
|
filepath.Join(directory, "stub-bun"),
|
||||||
|
"#!/bin/sh\necho 'port is already in use' >&2\nexit 1\n",
|
||||||
|
)
|
||||||
|
port := freePortRange(t, 1)
|
||||||
|
|
||||||
|
running, err := startServer(
|
||||||
|
context.Background(),
|
||||||
|
config{bunPath: bunPath},
|
||||||
|
implementation{
|
||||||
|
Name: "impl-01",
|
||||||
|
Directory: directory,
|
||||||
|
Port: port,
|
||||||
|
},
|
||||||
|
filepath.Join(directory, "serve.log"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer running.stop()
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
err = running.waitReady(context.Background(), readinessURL(port))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal(
|
||||||
|
"a server that exited should not be waited on until the start timeout",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(started); elapsed > 30*time.Second {
|
||||||
|
t.Errorf("waited %s for a server that had already exited", elapsed)
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(directory, "serve.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(log) == "" {
|
||||||
|
t.Error("serve.log did not capture why the server exited")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// implementation is one directory under --implementations, with the port it
|
||||||
|
// owns for the whole sweep. The port comes from the implementation's position
|
||||||
|
// in name order rather than from a pool, so the manifest can name the URL every
|
||||||
|
// run was served from before anything has been served.
|
||||||
|
type implementation struct {
|
||||||
|
Name string
|
||||||
|
Directory string
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
const implementationPrefix = "impl-"
|
||||||
|
|
||||||
|
func discoverImplementations(
|
||||||
|
directory string,
|
||||||
|
basePort int,
|
||||||
|
) ([]implementation, error) {
|
||||||
|
entries, err := os.ReadDir(directory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var found []implementation
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() ||
|
||||||
|
!strings.HasPrefix(entry.Name(), implementationPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = append(found, implementation{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Directory: filepath.Join(directory, entry.Name()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(found) == 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"no %s* directories in %s",
|
||||||
|
implementationPrefix,
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sort.Slice(
|
||||||
|
found,
|
||||||
|
func(i, j int) bool { return found[i].Name < found[j].Name },
|
||||||
|
)
|
||||||
|
if basePort+len(found)-1 > 65535 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"--base-port %d leaves no room for %d implementations",
|
||||||
|
basePort,
|
||||||
|
len(found),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for index := range found {
|
||||||
|
found[index].Port = basePort + index
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBinaries turns bun, campaign and sanderling into absolute paths before
|
||||||
|
// anything is installed. Each campaign runs from the sweep's own directory
|
||||||
|
// rather than the implementation's, so a relative --sanderling would otherwise
|
||||||
|
// resolve against the wrong one, and a binary that is missing altogether has to
|
||||||
|
// stop the sweep here rather than fail once per implementation and seed.
|
||||||
|
func resolveBinaries(configuration *config) error {
|
||||||
|
for name, value := range map[string]*string{
|
||||||
|
"--bun": &configuration.bunPath,
|
||||||
|
"--campaign": &configuration.campaignPath,
|
||||||
|
"--sanderling": &configuration.sanderlingPath,
|
||||||
|
} {
|
||||||
|
resolved, err := exec.LookPath(*value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
absolute, err := filepath.Abs(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*value = absolute
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type sweep struct {
|
||||||
|
configuration config
|
||||||
|
stdout io.Writer
|
||||||
|
records io.Writer
|
||||||
|
mutex sync.Mutex
|
||||||
|
stalled int
|
||||||
|
failedRuns int
|
||||||
|
totalRuns int
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSweep(
|
||||||
|
ctx context.Context,
|
||||||
|
configuration config,
|
||||||
|
stdout io.Writer,
|
||||||
|
) error {
|
||||||
|
if _, err := os.Stat(filepath.Join(configuration.outputDirectory, manifestFileName)); err == nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s already exists in %s: pick a fresh --output so two sweeps do not share a directory",
|
||||||
|
manifestFileName,
|
||||||
|
configuration.outputDirectory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
implementations, err := discoverImplementations(
|
||||||
|
configuration.implementationsDirectory,
|
||||||
|
configuration.basePort,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := resolveBinaries(&configuration); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(configuration.specPath); err != nil {
|
||||||
|
return fmt.Errorf("--spec: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(configuration.outputDirectory, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create sweep dir: %w", err)
|
||||||
|
}
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
if err := writeManifest(configuration.outputDirectory, buildManifest(configuration, implementations, host, time.Now().UTC())); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", manifestFileName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordsFile, err := os.OpenFile(
|
||||||
|
filepath.Join(configuration.outputDirectory, recordsFileName),
|
||||||
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", recordsFileName, err)
|
||||||
|
}
|
||||||
|
defer recordsFile.Close()
|
||||||
|
|
||||||
|
running := &sweep{
|
||||||
|
configuration: configuration,
|
||||||
|
stdout: stdout,
|
||||||
|
records: recordsFile,
|
||||||
|
}
|
||||||
|
fmt.Fprintf(
|
||||||
|
stdout,
|
||||||
|
"sweep: %d implementations, %d seeds each, %d at a time, %s\n",
|
||||||
|
len(
|
||||||
|
implementations,
|
||||||
|
),
|
||||||
|
len(configuration.seeds),
|
||||||
|
configuration.concurrency,
|
||||||
|
configuration.outputDirectory,
|
||||||
|
)
|
||||||
|
running.work(ctx, implementations)
|
||||||
|
|
||||||
|
fmt.Fprintf(
|
||||||
|
stdout,
|
||||||
|
"sweep complete: %d of %d implementations never ran, %d of %d campaigns failed\n",
|
||||||
|
running.stalled,
|
||||||
|
len(implementations),
|
||||||
|
running.failedRuns,
|
||||||
|
running.totalRuns,
|
||||||
|
)
|
||||||
|
if running.stalled > 0 || running.failedRuns > 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%d of %d implementations never ran and %d of %d campaigns failed; see %s",
|
||||||
|
running.stalled,
|
||||||
|
len(implementations),
|
||||||
|
running.failedRuns,
|
||||||
|
running.totalRuns,
|
||||||
|
recordsFileName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sweep) work(ctx context.Context, implementations []implementation) {
|
||||||
|
queue := make(chan implementation, len(implementations))
|
||||||
|
for _, target := range implementations {
|
||||||
|
queue <- target
|
||||||
|
}
|
||||||
|
close(queue)
|
||||||
|
|
||||||
|
workers := min(s.configuration.concurrency, len(implementations))
|
||||||
|
var waitGroup sync.WaitGroup
|
||||||
|
for range workers {
|
||||||
|
waitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
for target := range queue {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.report(s.runImplementation(ctx, target))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
waitGroup.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runImplementation carries one implementation from install to its last seed.
|
||||||
|
// Every failure it can meet is returned in the record: one implementation that
|
||||||
|
// cannot install, build or serve must not cost the other twenty-three their
|
||||||
|
// runs.
|
||||||
|
func (s *sweep) runImplementation(
|
||||||
|
ctx context.Context,
|
||||||
|
target implementation,
|
||||||
|
) (record implementationRecord) {
|
||||||
|
record = implementationRecord{
|
||||||
|
Name: target.Name,
|
||||||
|
Directory: target.Directory,
|
||||||
|
Port: target.Port,
|
||||||
|
StartedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
defer func() { record.MonotonicMillis = time.Since(started).Milliseconds() }()
|
||||||
|
|
||||||
|
directory := filepath.Join(s.configuration.outputDirectory, target.Name)
|
||||||
|
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||||
|
record.FailedStage = stageInstall
|
||||||
|
record.Error = err.Error()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
for _, step := range []struct {
|
||||||
|
stage string
|
||||||
|
arguments []string
|
||||||
|
}{
|
||||||
|
{stageInstall, []string{"install"}},
|
||||||
|
{stageBuild, []string{"run", "build"}},
|
||||||
|
} {
|
||||||
|
logPath := filepath.Join(directory, step.stage+".log")
|
||||||
|
exitCode, err := runCommand(
|
||||||
|
ctx,
|
||||||
|
target.Directory,
|
||||||
|
s.configuration.bunPath,
|
||||||
|
step.arguments,
|
||||||
|
logPath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
record.FailedStage = step.stage
|
||||||
|
record.Error = err.Error()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
if exitCode != 0 {
|
||||||
|
record.FailedStage = step.stage
|
||||||
|
record.Error = fmt.Sprintf(
|
||||||
|
"bun %s exited %d, see %s",
|
||||||
|
strings.Join(step.arguments, " "),
|
||||||
|
exitCode,
|
||||||
|
logPath,
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err := startServer(
|
||||||
|
ctx,
|
||||||
|
s.configuration,
|
||||||
|
target,
|
||||||
|
filepath.Join(directory, "serve.log"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
record.FailedStage = stageServe
|
||||||
|
record.Error = err.Error()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
defer running.stop()
|
||||||
|
if err := running.waitReady(ctx, readinessURL(target.Port)); err != nil {
|
||||||
|
record.FailedStage = stageServe
|
||||||
|
record.Error = err.Error()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, seed := range s.configuration.seeds {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
record.Runs = append(record.Runs, s.runSeed(ctx, target, seed))
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sweep) runSeed(
|
||||||
|
ctx context.Context,
|
||||||
|
target implementation,
|
||||||
|
seed int64,
|
||||||
|
) (record runRecord) {
|
||||||
|
seedText := strconv.FormatInt(seed, 10)
|
||||||
|
directory := campaignDirectory(s.configuration, target, seedText)
|
||||||
|
record = runRecord{
|
||||||
|
Seed: seed,
|
||||||
|
URL: servedURL(target.Port, seedText),
|
||||||
|
CampaignDirectory: directory,
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
defer func() { record.MonotonicMillis = time.Since(started).Milliseconds() }()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||||
|
record.ExitCode = -1
|
||||||
|
record.LaunchError = err.Error()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
exitCode, err := runCommand(
|
||||||
|
ctx,
|
||||||
|
"",
|
||||||
|
s.configuration.campaignPath,
|
||||||
|
campaignArguments(
|
||||||
|
s.configuration,
|
||||||
|
target,
|
||||||
|
seedText,
|
||||||
|
),
|
||||||
|
filepath.Join(directory, "campaign.log"),
|
||||||
|
)
|
||||||
|
record.ExitCode = exitCode
|
||||||
|
if err != nil {
|
||||||
|
record.LaunchError = err.Error()
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCommand runs one step of the pipeline with its output in logPath. An
|
||||||
|
// empty directory keeps the sweep's own working directory, which is what the
|
||||||
|
// campaign tool gets: it has no reason to run inside an implementation.
|
||||||
|
func runCommand(
|
||||||
|
ctx context.Context,
|
||||||
|
directory, binary string,
|
||||||
|
arguments []string,
|
||||||
|
logPath string,
|
||||||
|
) (int, error) {
|
||||||
|
logFile, err := os.Create(logPath)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
defer logFile.Close()
|
||||||
|
command := exec.CommandContext(ctx, binary, arguments...)
|
||||||
|
command.Dir = directory
|
||||||
|
command.Stdout = logFile
|
||||||
|
command.Stderr = logFile
|
||||||
|
err = command.Run()
|
||||||
|
if err == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if errors.As(err, &exitError) {
|
||||||
|
return exitError.ExitCode(), nil
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sweep) report(record implementationRecord) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
if record.FailedStage != "" {
|
||||||
|
s.stalled++
|
||||||
|
}
|
||||||
|
s.totalRuns += len(record.Runs)
|
||||||
|
for _, run := range record.Runs {
|
||||||
|
if run.ExitCode != 0 {
|
||||||
|
s.failedRuns++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(s.records).Encode(record); err != nil {
|
||||||
|
fmt.Fprintf(s.stdout, "warning: %s record: %v\n", record.Name, err)
|
||||||
|
}
|
||||||
|
elapsed := time.Duration(record.MonotonicMillis) * time.Millisecond
|
||||||
|
if record.FailedStage != "" {
|
||||||
|
fmt.Fprintf(s.stdout, "%s port=%d failed at %s: %s (%s)\n",
|
||||||
|
record.Name, record.Port, record.FailedStage, record.Error, elapsed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failed := 0
|
||||||
|
for _, run := range record.Runs {
|
||||||
|
if run.ExitCode != 0 {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(s.stdout, "%s port=%d campaigns=%d failed=%d elapsed=%s\n",
|
||||||
|
record.Name, record.Port, len(record.Runs), failed, elapsed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscoverImplementations_NameOrderAndOnePortEach(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
for _, name := range []string{"impl-03", "impl-01", "impl-10", "impl-02", "scaffold", ".DS_Store"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(directory, name), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "impl-notes.md"), []byte("not a directory"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := discoverImplementations(directory, 5300)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []implementation{
|
||||||
|
{Name: "impl-01", Port: 5300},
|
||||||
|
{Name: "impl-02", Port: 5301},
|
||||||
|
{Name: "impl-03", Port: 5302},
|
||||||
|
{Name: "impl-10", Port: 5303},
|
||||||
|
}
|
||||||
|
if len(found) != len(want) {
|
||||||
|
t.Fatalf(
|
||||||
|
"got %d implementations, want %d: %v",
|
||||||
|
len(found),
|
||||||
|
len(want),
|
||||||
|
found,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for index, target := range found {
|
||||||
|
if target.Name != want[index].Name || target.Port != want[index].Port {
|
||||||
|
t.Errorf(
|
||||||
|
"position %d: got %s on %d, want %s on %d",
|
||||||
|
index,
|
||||||
|
target.Name,
|
||||||
|
target.Port,
|
||||||
|
want[index].Name,
|
||||||
|
want[index].Port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if target.Directory != filepath.Join(directory, want[index].Name) {
|
||||||
|
t.Errorf("%s directory: got %q", target.Name, target.Directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverImplementations_EmptyDirectoryIsRefused(t *testing.T) {
|
||||||
|
_, err := discoverImplementations(t.TempDir(), 5300)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no impl-* directories") {
|
||||||
|
t.Fatalf("got %v, want a refusal naming impl-*", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A binary that is not there fails once, before anything is installed, rather
|
||||||
|
// than twenty-four times after the sweep has spent its build time.
|
||||||
|
func TestRunSweep_StopsBeforeItInstallsAnythingWhenABinaryIsMissing(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
implementations := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(implementations, "impl-01"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output := filepath.Join(t.TempDir(), "campaigns")
|
||||||
|
configuration := config{
|
||||||
|
implementationsDirectory: implementations,
|
||||||
|
outputDirectory: output,
|
||||||
|
basePort: 5300,
|
||||||
|
concurrency: 1,
|
||||||
|
bunPath: "bun",
|
||||||
|
campaignPath: "campaign-that-is-not-installed",
|
||||||
|
sanderlingPath: "sanderling",
|
||||||
|
}
|
||||||
|
err := runSweep(t.Context(), configuration, os.Stdout)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "--campaign") {
|
||||||
|
t.Fatalf("got %v, want the missing campaign binary named", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
||||||
|
t.Errorf(
|
||||||
|
"the sweep created %s before it checked it could run: %v",
|
||||||
|
output,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSweep_RefusesADirectoryThatAlreadyHoldsASweep(t *testing.T) {
|
||||||
|
implementations := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(implementations, "impl-01"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(output, manifestFileName), []byte("{}"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configuration := config{
|
||||||
|
implementationsDirectory: implementations,
|
||||||
|
outputDirectory: output,
|
||||||
|
basePort: 5300,
|
||||||
|
concurrency: 1,
|
||||||
|
}
|
||||||
|
if err := runSweep(t.Context(), configuration, os.Stdout); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "already exists") {
|
||||||
|
t.Fatalf("got %v, want a refusal to reuse the directory", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user