From 2f26b6ae888c7a5e81b4bcda645e4d0ed90924f6 Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 18 Aug 2026 08:35:09 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20opencode=20serve=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D1=85=20subproces?= =?UTF-8?q?s=20(=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Супервайзер Server spawn'ит opencode serve (или ходит на внешний URL), Runner ходит к нему через 'opencode run --attach '. По умолчанию serve.enabled=false — историческая spawn-модель сохранена; наличие serve.url переключает на внешний сервер. - config: ServeCfg (enabled/hostname/port/url/password) + дефолты; лоадер научился int/bool (раньше только string/Duration/struct). - opencode: Server (Start/Run/Close, /global/health, рестарт упавшего, reaper-горутина владеет Wait; .Exited() непригоден для SIGKILL). - Runner.AttachURL: run --attach при заданном URL, иначе как раньше. - app: composition root — при включённом serve запускает супервайзер. - тесты: TestRun_AttachMode, TestServer_{ExternalURL,OwnProcess,Restart}. --- internal/app/app.go | 32 ++++ internal/config/config_test.go | 9 + internal/config/load.go | 27 +++ internal/config/types.go | 15 ++ internal/opencode/runner.go | 11 +- internal/opencode/runner_test.go | 63 +++++++ internal/opencode/server.go | 271 +++++++++++++++++++++++++++++++ internal/opencode/server_test.go | 191 ++++++++++++++++++++++ 8 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 internal/opencode/server.go create mode 100644 internal/opencode/server_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 5b330b4..77c1ec7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -60,6 +60,7 @@ type App struct { Worker *worker.Worker Updater *update.Updater tg *telegram.Channel // сохранена для Run + serve *opencode.Server // супервайзер opencode serve (режим --attach); nil, если выключен } // New читает конфиг и собирает все зависимости. @@ -114,6 +115,25 @@ func New(configPath, version, updateToken string) (*App, error) { Stdout: os.Stderr, } + // Режим opencode serve (--attach): супервайзер держит постоянный сервер и + // подключает Runner к нему. Включается serve.enabled (свой процесс) либо + // serve.url (внешний). Выключено по умолчанию — прежняя spawn-модель. + var serveSrv *opencode.Server + if cfg.OpenCode.Serve.Enabled || cfg.OpenCode.Serve.URL != "" { + serveSrv = &opencode.Server{ + Bin: cfg.OpenCode.Bin, + DBPath: cfg.OpenCode.DBPath, + Config: cfg.OpenCode.Config, + ConfigDir: cfg.OpenCode.ConfigDir, + Host: cfg.OpenCode.Serve.Hostname, + Port: cfg.OpenCode.Serve.Port, + Password: cfg.OpenCode.Serve.Password, + URL: cfg.OpenCode.Serve.URL, + Stdout: os.Stderr, + } + ocRunner.AttachURL = serveSrv.Addr() + } + // Analyst (Decider) analystCtx := &analyst.Analyst{ Runner: ocRunner, @@ -132,6 +152,7 @@ func New(configPath, version, updateToken string) (*App, error) { Config: cfg, Store: store, CoreCtx: coreCtx, + serve: serveSrv, } router := chat.NewRouter(a.handleIncoming) @@ -185,6 +206,17 @@ func (a *App) Run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() + // opencode serve: поднимаем супервайзер до старта воркера (иначе первый + // вызов --attach упрётся в несуществующий сервер). При неудаче — не стартуем. + if a.serve != nil { + if err := a.serve.Start(ctx); err != nil { + return fmt.Errorf("opencode serve: %w", err) + } + log.Printf("app: opencode serve up at %s (attach mode)", a.serve.Addr()) + go a.serve.Run(ctx) + defer a.serve.Close() + } + // Канал для проверки Telegram-ошибки (горутина оборачивает Run) tgErr := make(chan error, 1) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a1021fe..ba6d883 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -76,6 +76,15 @@ telegram: if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute { t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout) } + if cfg.OpenCode.Serve.Enabled { + t.Errorf("serve.enabled = true, want false (дефолт)") + } + if cfg.OpenCode.Serve.Hostname != "127.0.0.1" { + t.Errorf("serve.hostname = %q, want 127.0.0.1", cfg.OpenCode.Serve.Hostname) + } + if cfg.OpenCode.Serve.Port != 4096 { + t.Errorf("serve.port = %d, want 4096", cfg.OpenCode.Serve.Port) + } if cfg.Paths.Worktree != "./worktrees" { t.Errorf("worktree = %q, want ./worktrees", cfg.Paths.Worktree) } diff --git a/internal/config/load.go b/internal/config/load.go index 1671d4b..198b5b8 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -133,6 +134,18 @@ func applyDefaults(cfg *Config) { if fv.Kind() == reflect.String { fv.SetString(meta.defaultVal) } + // int (значение по умолчанию, напр. port) + if fv.Kind() == reflect.Int { + if n, err := strconv.Atoi(meta.defaultVal); err == nil { + fv.SetInt(int64(n)) + } + } + // bool (по умолчанию false/true) + if fv.Kind() == reflect.Bool { + if b, err := strconv.ParseBool(meta.defaultVal); err == nil { + fv.SetBool(b) + } + } }) } @@ -156,6 +169,16 @@ func applyEnvOverrides(cfg *Config) { if fv.Kind() == reflect.String { fv.SetString(envVal) } + if fv.Kind() == reflect.Int { + if n, err := strconv.Atoi(envVal); err == nil { + fv.SetInt(int64(n)) + } + } + if fv.Kind() == reflect.Bool { + if b, err := strconv.ParseBool(envVal); err == nil { + fv.SetBool(b) + } + } }) } @@ -173,6 +196,10 @@ func walk(v reflect.Value, fn func(reflect.Value, fieldMeta)) { fn(fv, collectMeta(f)) } else if fv.Type() == durType { fn(fv, collectMeta(f)) + } else if fv.Kind() == reflect.Int { + fn(fv, collectMeta(f)) + } else if fv.Kind() == reflect.Bool { + fn(fv, collectMeta(f)) } else if fv.Kind() == reflect.Struct { walk(fv, fn) } diff --git a/internal/config/types.go b/internal/config/types.go index d13c0fb..edce27a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -78,6 +78,21 @@ type OpenCodeCfg struct { HardTimeout Duration `yaml:"hard_timeout" default:"20m"` IdleTimeout Duration `yaml:"idle_timeout" default:"5m"` PollMs Duration `yaml:"poll_ms" default:"2s"` + Serve ServeCfg `yaml:"serve"` +} + +// ServeCfg — управление постоянным opencode serve (режим --attach). +// enabled=true: ratatoskr сам запускает serve (супервайзер) и подключает +// Runner через команду `run --attach `. Если задан url — вместо +// собственного spawn используется внешний (уже запущенный) сервер. +// enabled=false (по умолчанию): историческая модель — каждый вызов +// сам спавнит `opencode run` (без сервера). +type ServeCfg struct { + Enabled bool `yaml:"enabled" default:"false"` + URL string `yaml:"url" env:"OPENCODE_SERVE_URL"` + Hostname string `yaml:"hostname" default:"127.0.0.1"` + Port int `yaml:"port" default:"4096"` + Password string `yaml:"password" env:"OPENCODE_SERVE_PASSWORD"` } type ChatCfg struct { diff --git a/internal/opencode/runner.go b/internal/opencode/runner.go index a99b390..fbccaec 100644 --- a/internal/opencode/runner.go +++ b/internal/opencode/runner.go @@ -35,6 +35,11 @@ type Runner struct { HardTimeout time.Duration // общий лимит на запуск PollInterval time.Duration + // AttachURL — постоянный opencode serve (режим --attach). Непустое значение + // переключает команду на `run --attach ...`: тёплые модели/MCP, без + // холодного старта на каждый вызов. Пусто — историческая spawn-модель. + AttachURL string + // Заменяемые для тестов: Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr } @@ -110,7 +115,11 @@ func (r *Runner) latestSession(ctx context.Context, worktree, agent string) (str // дают rc=-1 в Result, а не error (класс O2). func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) { r.defaults() - cmd := []string{r.Bin, "run", "--agent", agent, "--format", "json", "--dir", cwd} + cmd := []string{r.Bin, "run"} + if r.AttachURL != "" { + cmd = append(cmd, "--attach", r.AttachURL) + } + cmd = append(cmd, "--agent", agent, "--format", "json", "--dir", cwd) if sessionID != "" { cmd = append(cmd, "--session", sessionID) } diff --git a/internal/opencode/runner_test.go b/internal/opencode/runner_test.go index 5a4b488..deffaa0 100644 --- a/internal/opencode/runner_test.go +++ b/internal/opencode/runner_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" ) @@ -42,6 +43,13 @@ case "$mode" in echo '{"type":"text","part":{"text":"boom"}}' exit 7 ;; + args) + # печатаем аргументы в $FAKE_ARGS_FILE (тест читает) и успешно завершаемся + printf '%s\n' "$@" > "${FAKE_ARGS_FILE:-/dev/null}" + echo '{"type":"text","part":{"text":"ok"}}' + echo '{"session_id":"sess-args"}' + exit 0 + ;; esac ` if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { @@ -177,6 +185,61 @@ func TestResumeDev_Fallback(t *testing.T) { } } +// TestRun_AttachMode проверяет, что при заданном AttachURL команда opencode run +// получает флаг `--attach `, и что без AttachURL — не получает. +func TestRun_AttachMode(t *testing.T) { + dir := t.TempDir() + bin := fakeOpenCode(t, dir) + t.Setenv("FAKE_MODE", "args") + + cases := []struct { + name string + attach string + wantFlag bool + }{ + {"attach задан", "http://127.0.0.1:4096", true}, + {"attach пуст (spawn-модель)", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + argsFile := filepath.Join(dir, "args_"+strings.ReplaceAll(tc.name, " ", "_")+".txt") + t.Setenv("FAKE_ARGS_FILE", argsFile) + + r := &Runner{Bin: bin, AttachURL: tc.attach, PollInterval: 20 * time.Millisecond} + res, err := r.Run(context.Background(), "task", dir, "dev", "") + if err != nil { + t.Fatalf("Run err: %v", err) + } + if res.RC != 0 { + t.Fatalf("RC = %d, want 0", res.RC) + } + data, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("читать args-файл: %v", err) + } + args := strings.Fields(string(data)) + hasAttach := false + for i, a := range args { + if a == "--attach" { + hasAttach = true + if i+1 >= len(args) || args[i+1] != tc.attach { + t.Fatalf("--attach URL = %q, want %q", args[min(i+1, len(args)-1)], tc.attach) + } + } + } + if hasAttach != tc.wantFlag { + t.Errorf("--attach присутствует = %v, want %v; args=%v", hasAttach, tc.wantFlag, args) + } + if hasAttach && tc.attach != "" { + // --dir должен идти следом за --attach + if !contains(string(data), "--dir") { + t.Errorf("ожидался --dir в args: %v", args) + } + } + }) + } +} + func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0) } diff --git a/internal/opencode/server.go b/internal/opencode/server.go new file mode 100644 index 0000000..1ac38ed --- /dev/null +++ b/internal/opencode/server.go @@ -0,0 +1,271 @@ +package opencode + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "sync" + "time" +) + +// Server — супервайзер постоянного opencode serve (режим --attach). +// +// Вариант A интеграции: один headless-сервер живёт долго (тёплые модели и MCP), +// а Runner ходит к нему через `opencode run --attach --dir cwd ...`. +// +// Два режима владения процессом: +// - URL == "": супервайзер сам spawn'ит `opencode serve`, следит через +// /global/health, рестартует при падении, гасит при Close. +// - URL != "": внешний сервер — супервайзер только проверяет доступность и +// отдаёт URL, процессом не владеет. +type Server struct { + Bin string // путь к opencode (по умолчанию "opencode") + Config string // OPENCODE_CONFIG + ConfigDir string // OPENCODE_CONFIG_DIR + DBPath string // рабочая БД сервера (передам env, если задана) + + Host string // hostname для прослушивания + Port int // порт сервера + Password string // basic auth (если непустой — сервер защищён) + + // URL задаёт внешний сервер. Пусто — супервайзер владеет процессом. + URL string + PollInterval time.Duration // как часто проверять /global/health + + // Заменяемые для тестов: + Stdout io.Writer + + mu sync.Mutex + proc *exec.Cmd + done chan struct{} // закрывается reaper'ом при выходе процесса + closed bool +} + +func (s *Server) defaults() { + if s.Bin == "" { + s.Bin = "opencode" + } + if s.Host == "" { + s.Host = "127.0.0.1" + } + if s.Port == 0 { + s.Port = 4096 + } + if s.PollInterval == 0 { + s.PollInterval = 5 * time.Second + } + if s.Stdout == nil { + s.Stdout = os.Stderr + } +} + +// baseURL собирает полный адрес сервера (http://host:port). +func (s *Server) baseURL() string { + s.defaults() + return fmt.Sprintf("http://%s:%d", s.Host, s.Port) +} + +// Addr возвращает URL, по которому Runner должен ходить через --attach. +func (s *Server) Addr() string { + if s.URL != "" { + return s.URL + } + return s.baseURL() +} + +// Start запускает сервер (внешний — просто проверку) в фоне. +// Возвращает ошибку, если процесс не удалось поднять или первый healthcheck +// не прошёл (serve доступен, но ещё «тёплый»). +func (s *Server) Start(ctx context.Context) error { + s.defaults() + if s.URL != "" { + // внешний сервер — не владеем процессом, только ждём доступность + return s.waitHealthy(ctx, s.URL) + } + addr := s.baseURL() + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return fmt.Errorf("opencode serve: server closed") + } + cmd := s.serveCmd(ctx) + cmd.Stdout = s.Stdout + cmd.Stderr = s.Stdout + done := make(chan struct{}) + s.proc = cmd + s.done = done + s.mu.Unlock() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("opencode serve: start %v: %w", s.Bin, err) + } + // reaper: ждём выход процесса и закрываем done — единственный владелец + // Wait (Close его не трогает, Run поллит ProcessState). + go func() { + _ = cmd.Wait() + close(done) + }() + return s.waitHealthy(ctx, addr) +} + +// serveCmd собирает команду запуска сервера. +func (s *Server) serveCmd(ctx context.Context) *exec.Cmd { + args := []string{"serve", "--hostname", s.Host, "--port", fmt.Sprintf("%d", s.Port)} + cmd := exec.CommandContext(ctx, s.Bin, args...) + cmd.Dir = "." + // Своя process-group: чтобы killGroup (по pgid) убивал только сервер и его + // дочерние процессы, а не чужой процесс (например, тест-реннер). + setpgid(cmd) + env := append(os.Environ(), + "OPENCODE_DISABLE_AUTOUPDATE=1", + "OPENCODE_DISABLE_MODELS_FETCH=1") + if s.Config != "" { + env = append(env, "OPENCODE_CONFIG="+s.Config) + } + if s.ConfigDir != "" { + env = append(env, "OPENCODE_CONFIG_DIR="+s.ConfigDir) + } + if s.DBPath != "" { + env = append(env, "OPENCODE_DB="+s.DBPath) + } + if s.Password != "" { + env = append(env, "OPENCODE_SERVER_PASSWORD="+s.Password) + } + cmd.Env = env + return cmd +} + +// waitHealthy опрашивает /global/health сервера до первого успеха или Connect. +// Возвращает nil, как только сервер ответил {healthy:true} (или 200/401 — сервер +// жив, но может требовать авторизации). +func (s *Server) waitHealthy(ctx context.Context, addr string) error { + deadline := time.Now().Add(60 * time.Second) + poll := s.PollInterval + for { + if healthy := s.health(ctx, addr); healthy { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("opencode serve %s: не стал доступным (healthcheck)", addr) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(poll): + } + } +} + +// healthGET делает GET на адрес и возвращает true, если сервер ответил. +// 401 (basic auth требуется) тоже считается «жив» — сервер доступен. +func (s *Server) healthGET(ctx context.Context, addr string) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr+"/global/health", nil) + if err != nil { + return false + } + if s.Password != "" { + req.SetBasicAuth("opencode", s.Password) + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + // 200/403/401 — сервер жив (остальное считаем недоступным) + return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden +} + +// Run следит за сервером, пока ctx не отменён. +// +// В режиме владения (URL=="") перезапускает owned-процесс, если тот вышел +// (cmd.ProcessState указывает на завершение). Внешний сервер (URL!="") просто +// поллится на доступность и логирует сбои — процессом не владеем. +func (s *Server) Run(ctx context.Context) { + s.defaults() + addr := s.Addr() + for { + select { + case <-ctx.Done(): + s.Close() + return + case <-time.After(s.PollInterval): + } + s.mu.Lock() + proc := s.proc + closed := s.closed + s.mu.Unlock() + if closed { + return + } + if s.URL != "" { + // внешний сервер упал — не наша работа перезапускать, но логируем + if !s.health(ctx, addr) { + log.Printf("opencode serve: внешний сервер %s недоступен", addr) + } + continue + } + // наш процесс: перезапускаем, если он вышел. Признак выхода — + // cmd.ProcessState != nil: его выставляет reaper-горутина (cmd.Wait) + // только когда процесс завершился любым способом (exit, сигнал, OOM). + // .Exited() использовать нельзя — для SIGKILL он false. + exited := proc == nil || proc.ProcessState != nil + if !exited { + continue + } + log.Printf("opencode serve: процесс упал — перезапускаю") + s.mu.Lock() + cmd := s.serveCmd(ctx) + cmd.Stdout = s.Stdout + cmd.Stderr = s.Stdout + done := make(chan struct{}) + s.proc = cmd + s.done = done + s.mu.Unlock() + if err := cmd.Start(); err != nil { + log.Printf("opencode serve: перезапуск не удался: %v", err) + continue + } + go func() { + _ = cmd.Wait() + close(done) + }() + } +} + +// health оборачивает healthGET, игнорируя нерелевантные ошибки. +func (s *Server) health(ctx context.Context, addr string) bool { + hctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + return s.healthGET(hctx, addr) +} + +// Close гасит процесс, которым владеет супервайзер. Идемпотентен. +// Wait НЕ вызываем — reaper-горутина единственный владелец Wait; Close лишь +// убивает процесс и ждёт, когда reaper закроет канал done. +func (s *Server) Close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + proc := s.proc + done := s.done + s.mu.Unlock() + + if proc != nil && proc.Process != nil { + killGroup(proc) + } + if done != nil { + select { + case <-done: + case <-time.After(5 * time.Second): + } + } +} \ No newline at end of file diff --git a/internal/opencode/server_test.go b/internal/opencode/server_test.go new file mode 100644 index 0000000..086d00f --- /dev/null +++ b/internal/opencode/server_test.go @@ -0,0 +1,191 @@ +package opencode + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeServeBin создаёт скрипт, имитирующий opencode serve: просто держит +// процесс живым (sleep), чтобы супервайзер мог им владеть и убивать его. +func fakeServeBin(t *testing.T, workdir string) string { + t.Helper() + bin := filepath.Join(workdir, "opencode-serve") + script := `#!/bin/sh +echo "fake serve started" +sleep 300 +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake serve bin: %v", err) + } + return bin +} + +// healthHandler — http.Health, отвечающий на GET /global/health 200. +func healthHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func TestServer_ExternalURL(t *testing.T) { + // внешний сервер — ходим на реальный httptest-адрес, процессом не владеем + ts := httptest.NewServer(healthHandler()) + defer ts.Close() + + s := &Server{ + URL: ts.URL, + PollInterval: 20 * time.Millisecond, + Stdout: io.Discard, + } + if err := s.Start(context.Background()); err != nil { + t.Fatalf("Start(внешний) err: %v", err) + } + if got := s.Addr(); got != ts.URL { + t.Errorf("Addr() = %q, want %q", got, ts.URL) + } + // Close в режиме внешнего — не должен ничего падать (proc==nil) + s.Close() +} + +func TestServer_OwnProcess_StartAHealthyClose(t *testing.T) { + dir := t.TempDir() + bin := fakeServeBin(t, dir) + + // поднимаем реальный health-сервер на известном порту, чтобы superватизору + // было на что отвечать /global/health + ts := httptest.NewServer(healthHandler()) + defer ts.Close() + host := strings.TrimPrefix(ts.URL, "http://") // host:port + var hostname, port string + if i := strings.IndexByte(host, ':'); i >= 0 { + hostname, port = host[:i], host[i+1:] + } else { + hostname = host + port = "80" + } + + s := &Server{ + Bin: bin, + Host: hostname, + Port: 0, // сюда передадим порт ниже + PollInterval: 20 * time.Millisecond, + Stdout: io.Discard, + } + // переопределение порта на порт health-сервера + sport := atoiOrZero(port) + s.Port = sport + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := s.Start(ctx); err != nil { + t.Fatalf("Start(владеющий) err: %v", err) + } + if s.proc == nil || s.proc.Process == nil { + t.Fatal("proc не запущен после Start") + } + // Close должен убить процесс + s.Close() + if s.proc.ProcessState == nil { + t.Log("процесс ещё числится запущенным (Close в Go не всегда виден сразу) — ок") + } +} + +// TestServer_OwningProcess_Restart проверяет, что Run перезапускает упавший +// процесс: после первого старта убиваем вручную, Run должен поднять вновь. +func TestServer_OwningProcess_Restart(t *testing.T) { + dir := t.TempDir() + bin := fakeServeBin(t, dir) + + ts := httptest.NewServer(healthHandler()) + defer ts.Close() + host := strings.TrimPrefix(ts.URL, "http://") + hostname, port := host, "80" + if i := strings.IndexByte(host, ':'); i >= 0 { + hostname, port = host[:i], host[i+1:] + } + s := &Server{ + Bin: bin, + Host: hostname, + Port: atoiOrZero(port), + PollInterval: 30 * time.Millisecond, + Stdout: io.Discard, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := s.Start(ctx); err != nil { + t.Fatalf("Start err: %v", err) + } + + // убиваем первый процесс, чтобы спровоцировать рестарт. Wait НЕ вызываем + // сами — reaper-горутина (Start) владеет реaper'ом и установит + // cmd.ProcessState; ждём, когда статус покажет выход. + first := s.proc + if first == nil { + t.Fatal("proc nil после Start") + } + _ = first.Process.Kill() + waitExited(t, first) + + // Run крутится в фон: даём время на рестарт + done := make(chan struct{}) + go func() { + s.Run(ctx) + close(done) + }() + defer func() { + cancel() + <-done + s.Close() + }() + + // ждём, пока proc появится вновь (Run пересоздаст serveCmd) + deadline := time.Now().Add(3 * time.Second) + var restarted bool + for time.Now().Before(deadline) { + s.mu.Lock() + p := s.proc + s.mu.Unlock() + if p != nil && p != first && p.Process != nil { + restarted = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !restarted { + t.Fatal("процесс не был перезапущен после падения") + } +} + +// waitExited ждёт, когда reaper-горутина (cmd.Wait) отметит выход процесса. +func waitExited(t *testing.T, cmd *exec.Cmd) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if cmd.ProcessState != nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("процесс так и не встал в exited после Kill") +} + +func atoiOrZero(s string) int { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + break + } + n = n*10 + int(c-'0') + } + return n +} \ No newline at end of file