relay-flow 0.3.13-alpha → 0.3.14-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +39 -5
  2. package/cmd/relay-flow/commands_test.go +9 -0
  3. package/cmd/relay-flow/main.go +16 -7
  4. package/cmd/relay-flow/repo_registration.go +68 -0
  5. package/cmd/relay-flow/repo_selection_keyboard_test.go +178 -0
  6. package/cmd/relay-flow/scenario_test.go +25 -0
  7. package/cmd/relay-flow/serve.go +33 -2
  8. package/cmd/relay-flow/startup_alternate_plugins_test.go +138 -0
  9. package/cmd/relay-flow/startup_errors.go +85 -0
  10. package/cmd/relay-flow/startup_errors_test.go +88 -0
  11. package/cmd/relay-flow/startup_failure_categories_test.go +115 -0
  12. package/cmd/relay-flow/startup_ordering_test.go +75 -0
  13. package/cmd/relay-flow/startup_prerequisites_test.go +324 -0
  14. package/cmd/relay-flow/startup_readiness_test.go +116 -0
  15. package/cmd/relay-flow/startup_submission_test.go +123 -0
  16. package/internal/harness/opencode/opencode.go +14 -0
  17. package/internal/harness/opencode/opencode_test.go +1 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/harness/pi/pi.go +14 -0
  20. package/internal/harness/startup.go +25 -0
  21. package/internal/harness/startup_test.go +85 -0
  22. package/internal/runner/herdr/herdr.go +9 -0
  23. package/internal/runner/herdr/herdrcli/exec.go +14 -0
  24. package/internal/runner/herdr/herdrcli/operations.go +8 -2
  25. package/internal/runner/herdr/herdrcli/response.go +6 -0
  26. package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
  27. package/internal/runner/herdr/startup_test.go +58 -0
  28. package/internal/runner/orca/orca.go +9 -0
  29. package/internal/runner/orca/orcacli/orcacli.go +99 -34
  30. package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
  31. package/internal/runner/orca/orcacli/startup_test.go +158 -0
  32. package/internal/runner/orca/startup_test.go +57 -0
  33. package/internal/runner/startup.go +27 -0
  34. package/internal/runner/startup_test.go +59 -0
  35. package/internal/task/beads/bdcli/bdcli.go +12 -0
  36. package/internal/task/beads/bdcli/startup.go +20 -0
  37. package/internal/task/beads/bdcli/startup_test.go +27 -0
  38. package/internal/task/beads/beads.go +1 -0
  39. package/internal/task/beads/startup.go +75 -0
  40. package/internal/task/beads/startup_test.go +155 -0
  41. package/internal/task/factory.go +6 -2
  42. package/internal/task/jira/jira.go +4 -3
  43. package/internal/task/jira/rest/client.go +40 -6
  44. package/internal/task/jira/rest/startup_redirect_test.go +23 -0
  45. package/internal/task/jira/rest/startup_test.go +133 -0
  46. package/internal/task/jira/startup.go +22 -0
  47. package/internal/task/jira/startup_test.go +78 -0
  48. package/internal/task/startup.go +25 -0
  49. package/internal/task/startup_test.go +51 -0
  50. package/package.json +1 -1
@@ -0,0 +1,138 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/config"
13
+ "github.com/rajpopat27/relay-flow/internal/server"
14
+ )
15
+
16
+ func TestServeModesSupportBeadsHerdrPiWithoutReposAndRejectConfiguredConnectionFailures(t *testing.T) {
17
+ binary := buildCLIBinary(t)
18
+ for _, failure := range []string{"", "beads", "herdr"} {
19
+ for _, foreground := range []bool{false, true} {
20
+ name := failure
21
+ if name == "" {
22
+ name = "empty-healthy"
23
+ }
24
+ if foreground {
25
+ name += "-foreground"
26
+ }
27
+ t.Run(name, func(t *testing.T) {
28
+ root := filepath.Join(t.TempDir(), "relay-flow")
29
+ t.Setenv("RELAY_FLOW_HOME", root)
30
+ init := exec.Command(binary, "init", "--task-plugin", "beads", "--runner-plugin", "herdr", "--harness-plugin", "pi")
31
+ if out, err := init.CombinedOutput(); err != nil {
32
+ t.Fatalf("init: %v\n%s", err, out)
33
+ }
34
+ bin := t.TempDir()
35
+ logPath := filepath.Join(t.TempDir(), "calls")
36
+ t.Setenv("STARTUP_CALLS", logPath)
37
+ t.Setenv("HERDR_SESSION", "ambient-session")
38
+ t.Setenv("HERDR_SOCKET_PATH", "/ambient/socket")
39
+ t.Setenv("BEADS_DIR", "/ambient/beads")
40
+ t.Setenv("BEADS_DB", "/ambient/db")
41
+ p := pathsForRoot(root)
42
+ cfg, err := config.LoadMachine(p.Config)
43
+ if err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ cfg.RunnerConfig = config.RawValues{"session": "configured-session", "socketPath": "/configured/socket"}
47
+ bdScript := "#!/bin/sh\necho 'unexpected ambient workspace read' >&2\nexit 99\n"
48
+ if failure == "beads" {
49
+ code, workspace := t.TempDir(), t.TempDir()
50
+ cfg.Repos = map[string]config.Repo{"app": {Path: code, TaskConfig: config.RawValues{"beadsDir": workspace}}}
51
+ t.Setenv("EXPECTED_CWD", code)
52
+ t.Setenv("EXPECTED_BEADS_DIR", workspace)
53
+ bdScript = `#!/bin/sh
54
+ [ "$*" = 'list --ready --limit 1 --no-parent --json' ] || exit 99
55
+ [ "$PWD" = "$EXPECTED_CWD" ] || exit 98
56
+ [ "$BEADS_DIR" = "$EXPECTED_BEADS_DIR" ] || exit 97
57
+ [ -z "${BEADS_DB+x}" ] || exit 96
58
+ printf '%s\n' 'bd probe' >> "$STARTUP_CALLS"
59
+ printf '%s' 'Dolt connection refused' >&2
60
+ exit 1
61
+ `
62
+ }
63
+ herdrScript := `#!/bin/sh
64
+ [ "$*" = 'api snapshot' ] || exit 99
65
+ [ "$HERDR_SESSION" = 'configured-session' ] || exit 98
66
+ [ "$HERDR_SOCKET_PATH" = '/configured/socket' ] || exit 97
67
+ printf '%s\n' 'herdr snapshot' >> "$STARTUP_CALLS"
68
+ `
69
+ if failure == "herdr" {
70
+ herdrScript += "printf '%s' '{\"error\":{\"code\":\"connection_refused\",\"message\":\"socket unavailable\"}}' >&2\nexit 1\n"
71
+ } else {
72
+ herdrScript += "printf '%s' '{\"result\":{\"snapshot\":{\"workspaces\":[],\"tabs\":[],\"panes\":[]}}}'\n"
73
+ }
74
+ for name, script := range map[string]string{"bd": bdScript, "herdr": herdrScript, "pi": "#!/bin/sh\nexit 99\n"} {
75
+ if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ }
79
+ if err := config.SaveMachine(p.Config, cfg); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ t.Setenv("PATH", bin)
83
+ args := []string{"serve"}
84
+ if foreground {
85
+ args = append(args, "--foreground")
86
+ }
87
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
88
+ defer cancel()
89
+ serve := exec.CommandContext(ctx, binary, args...)
90
+ if failure != "" {
91
+ out, err := serve.CombinedOutput()
92
+ operation := "api snapshot"
93
+ if failure == "beads" {
94
+ operation = "Dolt connection refused"
95
+ }
96
+ if err == nil || !strings.Contains(string(out), operation) || !strings.Contains(string(out), p.ServerLog) || strings.Contains(string(out), "server started") {
97
+ t.Fatalf("configured connection failure skipped: %v\n%s", err, out)
98
+ }
99
+ } else {
100
+ client := server.NewClient(p.Socket)
101
+ if foreground {
102
+ if err := serve.Start(); err != nil {
103
+ t.Fatal(err)
104
+ }
105
+ wait := make(chan error, 1)
106
+ go func() { wait <- serve.Wait() }()
107
+ t.Cleanup(func() { _ = serve.Process.Kill() })
108
+ waitForServer(t, client)
109
+ if err := client.Stop(context.Background()); err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ if err := <-wait; err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ } else {
116
+ if out, err := serve.CombinedOutput(); err != nil || !strings.Contains(string(out), "server started") {
117
+ t.Fatalf("zero-repo readiness: %v\n%s", err, out)
118
+ }
119
+ if err := client.Stop(context.Background()); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ }
123
+ waitForServerStop(t, client)
124
+ if log := readFile(t, p.ServerLog); !strings.Contains(log, "deferred to registration") {
125
+ t.Fatalf("unconfigured Beads connection not reported deferred: %s", log)
126
+ }
127
+ }
128
+ wantCalls := "herdr snapshot\n"
129
+ if failure == "beads" {
130
+ wantCalls = "bd probe\n"
131
+ }
132
+ if calls := readFile(t, logPath); calls != wantCalls {
133
+ t.Fatalf("unselected/duplicate/full-discovery startup calls: %q", calls)
134
+ }
135
+ })
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,85 @@
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "log/slog"
7
+ "os"
8
+ "strconv"
9
+ "strings"
10
+ "unicode"
11
+ )
12
+
13
+ // Both foreground and the in-process CLI seam write the same owned record.
14
+ // PID lets the detached parent distinguish its child from a racing lock owner.
15
+ func logStartupFailure(err error) {
16
+ slog.Error("server startup failed", "pid", os.Getpid(), "error", startupDiagnostic(err.Error()))
17
+ }
18
+
19
+ func startupLogOffset(path string) int64 {
20
+ info, err := os.Stat(path)
21
+ if os.IsNotExist(err) {
22
+ return 0
23
+ }
24
+ if err != nil {
25
+ return -1 // Cannot establish this attempt's boundary: do not replay logs.
26
+ }
27
+ return info.Size()
28
+ }
29
+
30
+ // readStartupFailure reads only this attempt's bounded tail, and only the
31
+ // selected child's owned startup-failure record. All other log content stays
32
+ // in server.log. Missing/truncated/unreadable records leave the normal exit
33
+ // diagnostic and log location intact, never substitute an older error.
34
+ func readStartupFailure(path string, offset int64, pid int) string {
35
+ if offset < 0 || pid <= 0 {
36
+ return ""
37
+ }
38
+ f, err := os.Open(path)
39
+ if err != nil {
40
+ return ""
41
+ }
42
+ defer f.Close()
43
+ info, err := f.Stat()
44
+ if err != nil || info.Size() <= offset {
45
+ return ""
46
+ }
47
+ const maxDiagnosticBytes int64 = 64 << 10
48
+ start := max(offset, info.Size()-maxDiagnosticBytes)
49
+ if _, err := f.Seek(start, io.SeekStart); err != nil {
50
+ return ""
51
+ }
52
+ data, err := io.ReadAll(io.LimitReader(f, maxDiagnosticBytes))
53
+ if err != nil {
54
+ return ""
55
+ }
56
+ // logging.Setup owns this text-handler record and its field order.
57
+ marker := fmt.Sprintf(`msg="server startup failed" pid=%d error=`, pid)
58
+ lines := strings.Split(string(data), "\n")
59
+ for i := len(lines) - 1; i >= 0; i-- {
60
+ index := strings.Index(lines[i], marker)
61
+ if index < 0 {
62
+ continue
63
+ }
64
+ value := strings.TrimSpace(lines[i][index+len(marker):])
65
+ if strings.HasPrefix(value, `"`) {
66
+ value, err = strconv.Unquote(value)
67
+ if err != nil {
68
+ return ""
69
+ }
70
+ }
71
+ return startupDiagnostic(value)
72
+ }
73
+ return ""
74
+ }
75
+
76
+ // Credential redaction belongs to the adapter. This final display step keeps
77
+ // external control characters out of both foreground and detached caller TUIs.
78
+ func startupDiagnostic(message string) string {
79
+ return strings.Map(func(r rune) rune {
80
+ if unicode.IsControl(r) {
81
+ return ' '
82
+ }
83
+ return r
84
+ }, message)
85
+ }
@@ -0,0 +1,88 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log/slog"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+ )
13
+
14
+ func TestStartupDiagnosticReadsOnlyCurrentAttemptAndChild(t *testing.T) {
15
+ path := filepath.Join(t.TempDir(), "server.log")
16
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ defer file.Close()
21
+ log := slog.New(slog.NewTextHandler(file, nil))
22
+ log.Error("server startup failed", "pid", 123, "error", errors.New("stale same-pid error"))
23
+ offset := startupLogOffset(path)
24
+ log.Error("server startup failed", "pid", 456, "error", errors.New("another child's error"))
25
+ log.Warn("unrelated warning", "error", "private warning payload")
26
+ if got := readStartupFailure(path, offset, 123); got != "" {
27
+ t.Fatalf("replayed stale or unrelated diagnostic: %q", got)
28
+ }
29
+ failure := `startup prerequisite runner plugin "orca": orca repo list: connection refused`
30
+ log.Error("server startup failed", "pid", 123, "error", errors.New(failure))
31
+ if got := readStartupFailure(path, offset, 123); got != failure {
32
+ t.Fatalf("current child diagnostic = %q, want %q", got, failure)
33
+ }
34
+ if got := readStartupFailure(path, offset, 12); got != "" {
35
+ t.Fatalf("PID prefix matched another child: %q", got)
36
+ }
37
+ }
38
+
39
+ func TestStartupDiagnosticIsBoundedAndDoesNotReplayTruncatedOrUnreadableLogs(t *testing.T) {
40
+ path := filepath.Join(t.TempDir(), "server.log")
41
+ old := fmt.Sprintf("msg=\"server startup failed\" pid=123 error=%q\n", "stale error")
42
+ if err := os.WriteFile(path, []byte(old+strings.Repeat("unrelated log data\n", 10000)), 0o600); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if got := readStartupFailure(path, 0, 123); got != "" {
46
+ t.Fatalf("reader scanned beyond bounded tail: %q", got)
47
+ }
48
+ offset := startupLogOffset(path)
49
+ if err := os.WriteFile(path, []byte(old), 0o600); err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ if got := readStartupFailure(path, offset, 123); got != "" {
53
+ t.Fatalf("replayed truncated log: %q", got)
54
+ }
55
+ for _, offset := range []int64{-1, 1000} {
56
+ if got := readStartupFailure(path, offset, 123); got != "" {
57
+ t.Fatalf("invalid boundary replayed old error: %q", got)
58
+ }
59
+ }
60
+ if got := readStartupFailure(path+"-absent", 0, 123); got != "" {
61
+ t.Fatalf("missing log returned diagnostic: %q", got)
62
+ }
63
+ }
64
+
65
+ func TestStartupDiagnosticRemovesTerminalControlCharacters(t *testing.T) {
66
+ path := filepath.Join(t.TempDir(), "server.log")
67
+ line := fmt.Sprintf("msg=\"server startup failed\" pid=123 error=%q\n", "orca repo list: \x1b[31mconnection\nrefused")
68
+ if err := os.WriteFile(path, []byte(line), 0o600); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ got := readStartupFailure(path, 0, 123)
72
+ if strings.ContainsAny(got, "\x1b\n\r") || !strings.Contains(got, "connection refused") {
73
+ t.Fatalf("unsafe diagnostic: %q", got)
74
+ }
75
+ }
76
+
77
+ func TestGuidedStartupPropagatesInProcessFailureAndWritesOwnedLogRecord(t *testing.T) {
78
+ failure := errors.New("task connection refused")
79
+ f := newPrerequisiteFixture(t, func(context.Context) error { return failure })
80
+ err := ensureFirstRunServer(f.paths)
81
+ if err == nil || !strings.Contains(err.Error(), "task connection refused") || !strings.Contains(err.Error(), f.cfg.TaskPlugin) || !strings.Contains(err.Error(), f.paths.ServerLog) {
82
+ t.Fatalf("guided startup lost dependency or log location: %v", err)
83
+ }
84
+ got := readStartupFailure(f.paths.ServerLog, 0, os.Getpid())
85
+ if !strings.Contains(got, "startup prerequisite task plugin") || !strings.Contains(got, "task connection refused") {
86
+ t.Fatalf("in-process startup did not write owned failure record: %q", got)
87
+ }
88
+ }
@@ -0,0 +1,115 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "runtime"
12
+ "strings"
13
+ "sync/atomic"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/rajpopat27/relay-flow/internal/server"
18
+ )
19
+
20
+ func TestServeModesPropagateDependencyFailureCategoriesWithoutRetries(t *testing.T) {
21
+ binary := buildCLIBinary(t)
22
+ for _, tc := range []struct {
23
+ name, stage, script, want string
24
+ status int
25
+ body string
26
+ }{
27
+ {name: "runner-launch", stage: "runner", script: "#!/nonexistent/interpreter\n", want: "launch failure"},
28
+ {name: "runner-connection", stage: "runner", script: "#!/bin/sh\nprintf '%s' 'runtime connection refused' >&2\nexit 1\n", want: "connection refused"},
29
+ {name: "runner-malformed", stage: "runner", script: "#!/bin/sh\nprintf '%s' '{\"result\":{}}'\n", want: "malformed response"},
30
+ {name: "runner-timeout", stage: "runner", script: "#!/bin/sh\nexec /bin/sleep 30\n", want: "timeout"},
31
+ {name: "task-authentication", stage: "task", status: 401, body: "fixture-token startup@example.invalid", want: "authentication"},
32
+ {name: "task-connection", stage: "task", status: 503, body: "service unavailable", want: "HTTP 503"},
33
+ {name: "task-malformed", stage: "task", status: 200, body: "not-json", want: "malformed response"},
34
+ {name: "task-timeout", stage: "task", status: 0, want: "timeout"},
35
+ } {
36
+ for _, foreground := range []bool{false, true} {
37
+ t.Run(fmt.Sprintf("%s/foreground=%v", tc.name, foreground), func(t *testing.T) {
38
+ root := filepath.Join(t.TempDir(), "relay-flow")
39
+ t.Setenv("RELAY_FLOW_HOME", root)
40
+ init := exec.Command(binary, initArgs()...)
41
+ init.Env = os.Environ()
42
+ if out, err := init.CombinedOutput(); err != nil {
43
+ t.Fatalf("init: %v\n%s", err, out)
44
+ }
45
+ installHealthyServePrerequisites(t, root)
46
+ bin := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))[0]
47
+ t.Setenv("PATH", bin)
48
+ var calls atomic.Int32
49
+ operation := "repo list"
50
+ if tc.stage == "runner" {
51
+ launcher := "orca"
52
+ if runtime.GOOS == "linux" {
53
+ launcher = "orca-ide"
54
+ }
55
+ if err := os.WriteFile(filepath.Join(bin, launcher), []byte(tc.script), 0o755); err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ } else {
59
+ operation = "/rest/api/3/myself"
60
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61
+ calls.Add(1)
62
+ user, token, ok := r.BasicAuth()
63
+ if !ok || user != "startup@example.invalid" || token != "fixture-token" || r.Method != "GET" || r.URL.Path != "/rest/api/3/myself" {
64
+ t.Errorf("unexpected probe: %s %s", r.Method, r.URL.Path)
65
+ }
66
+ if tc.status == 0 {
67
+ <-r.Context().Done()
68
+ return
69
+ }
70
+ w.Header().Set("Retry-After", "60")
71
+ w.WriteHeader(tc.status)
72
+ _, _ = w.Write([]byte(tc.body))
73
+ }))
74
+ t.Cleanup(s.Close)
75
+ credentials := fmt.Sprintf("site: %q\nemail: startup@example.invalid\ntoken: fixture-token\n", s.URL)
76
+ if err := os.WriteFile(filepath.Join(root, "credentials.yaml"), []byte(credentials), 0o600); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ }
80
+ // An earlier run's record must never escape into this diagnostic.
81
+ logPath := filepath.Join(root, "server.log")
82
+ if err := os.WriteFile(logPath, []byte("msg=\"server startup failed\" pid=1 error=\"STALE_PRIVATE_ERROR\"\n"), 0o600); err != nil {
83
+ t.Fatal(err)
84
+ }
85
+ args := []string{"serve"}
86
+ if foreground {
87
+ args = append(args, "--foreground")
88
+ }
89
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
90
+ defer cancel()
91
+ serve := exec.CommandContext(ctx, binary, args...)
92
+ serve.Env = os.Environ()
93
+ start := time.Now()
94
+ out, err := serve.CombinedOutput()
95
+ if err == nil || strings.Contains(string(out), "server started") || !strings.Contains(string(out), tc.want) || !strings.Contains(string(out), operation) || !strings.Contains(string(out), logPath) {
96
+ t.Fatalf("failure category or safe identity lost: err=%v, output=%s", err, out)
97
+ }
98
+ for _, secret := range []string{"fixture-token", "startup@example.invalid", "STALE_PRIVATE_ERROR"} {
99
+ if strings.Contains(string(out), secret) {
100
+ t.Fatalf("startup diagnostic exposed secret/stale record %q: %s", secret, out)
101
+ }
102
+ }
103
+ if tc.stage == "task" && calls.Load() != 1 {
104
+ t.Fatalf("credential probe retried or queried project data: %d calls", calls.Load())
105
+ }
106
+ if elapsed := time.Since(start); elapsed > 7*time.Second {
107
+ t.Fatalf("startup probe inherited retries or exceeded ceiling: %v", elapsed)
108
+ }
109
+ if serverResponding(server.NewClient(filepath.Join(root, "server.sock")), 50*time.Millisecond) {
110
+ t.Fatal("failed probe left ready server")
111
+ }
112
+ })
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,75 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/server"
10
+ )
11
+
12
+ func TestServeDoesNotOpenExecutionStateOrPublishReadinessWhileProbeIsPending(t *testing.T) {
13
+ f := newPrerequisiteFixture(t, nil)
14
+ entered, release := make(chan struct{}), make(chan struct{})
15
+ f.hrn.probe = func(ctx context.Context) error {
16
+ close(entered)
17
+ select {
18
+ case <-release:
19
+ return nil
20
+ case <-ctx.Done():
21
+ return ctx.Err()
22
+ }
23
+ }
24
+ before, err := os.ReadFile(f.paths.Database)
25
+ if err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ ctx, cancel := context.WithCancel(context.Background())
29
+ done := make(chan error, 1)
30
+ go func() { done <- serveRoot(ctx, f.paths, false) }()
31
+ finished := false
32
+ t.Cleanup(func() {
33
+ cancel()
34
+ if !finished {
35
+ select {
36
+ case <-done:
37
+ case <-time.After(10 * time.Second):
38
+ t.Error("serveRoot did not stop")
39
+ }
40
+ }
41
+ })
42
+ select {
43
+ case <-entered:
44
+ case err := <-done:
45
+ finished = true
46
+ t.Fatalf("server exited before probe: %v", err)
47
+ case <-time.After(time.Second):
48
+ t.Fatal("startup probe did not begin")
49
+ }
50
+ client := server.NewClient(f.paths.Socket)
51
+ if serverResponding(client, 100*time.Millisecond) {
52
+ t.Fatal("pending prerequisite was mistaken for readiness")
53
+ }
54
+ if _, err := os.Stat(f.paths.Socket); !os.IsNotExist(err) {
55
+ t.Fatalf("socket opened before prerequisite completion: %v", err)
56
+ }
57
+ after, err := os.ReadFile(f.paths.Database)
58
+ if err != nil || string(after) != string(before) {
59
+ t.Fatalf("execution state opened/migrated before prerequisites: %v", err)
60
+ }
61
+ close(release)
62
+ waitForServerOrError(t, client, done)
63
+ if err := client.Stop(context.Background()); err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ select {
67
+ case err := <-done:
68
+ finished = true
69
+ if err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ case <-time.After(10 * time.Second):
73
+ t.Fatal("ready server did not stop")
74
+ }
75
+ }