feat: opencode serve — постоянный сервер вместо разовых subprocess (вариант A)
Супервайзер Server spawn'ит opencode serve (или ходит на внешний URL),
Runner ходит к нему через 'opencode run --attach <url>'. По умолчанию
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> при заданном URL, иначе как раньше.
- app: composition root — при включённом serve запускает супервайзер.
- тесты: TestRun_AttachMode, TestServer_{ExternalURL,OwnProcess,Restart}.
This commit is contained in:
@@ -35,6 +35,11 @@ type Runner struct {
|
||||
HardTimeout time.Duration // общий лимит на запуск
|
||||
PollInterval time.Duration
|
||||
|
||||
// AttachURL — постоянный opencode serve (режим --attach). Непустое значение
|
||||
// переключает команду на `run --attach <url> ...`: тёплые модели/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)
|
||||
}
|
||||
|
||||
@@ -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 <url>`, и что без 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)
|
||||
}
|
||||
|
||||
271
internal/opencode/server.go
Normal file
271
internal/opencode/server.go
Normal file
@@ -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 <url> --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):
|
||||
}
|
||||
}
|
||||
}
|
||||
191
internal/opencode/server_test.go
Normal file
191
internal/opencode/server_test.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user