e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
281 lines
8.2 KiB
Go
281 lines
8.2 KiB
Go
// Copyright 2026 Alibaba Group Holding Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package supervisor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// ErrBurstExceeded is returned by Run when the crashloop budget is exhausted
|
|
// and Spec.OnBurstExit is true.
|
|
var ErrBurstExceeded = errors.New("supervisor: crashloop budget exceeded")
|
|
|
|
// Run supervises the worker described by spec until ctx is cancelled or the
|
|
// crashloop budget is exhausted. It returns ctx.Err() on graceful shutdown,
|
|
// ErrBurstExceeded on burst exit, or a setup error if Spec is invalid.
|
|
func Run(ctx context.Context, spec Spec) error {
|
|
if spec.Cmd == "" {
|
|
return errors.New("supervisor: Spec.Cmd is required")
|
|
}
|
|
spec.applyDefaults()
|
|
|
|
ew := newEventWriter(spec.EventLog, spec.Name, spec.Clock.Now)
|
|
starts := newBurstTracker(spec.BurstMax, spec.BurstWindow, spec.Clock.Now)
|
|
|
|
var (
|
|
gen uint64
|
|
attempt int
|
|
backoff time.Duration
|
|
)
|
|
|
|
shutdown := func() error {
|
|
_ = ew.emit(Event{Event: EventShutdown, Reason: "ctx cancelled"})
|
|
return ctx.Err()
|
|
}
|
|
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return shutdown()
|
|
}
|
|
|
|
attempt++
|
|
curGen := atomic.AddUint64(&gen, 1)
|
|
|
|
// Pre-start hooks. Failure aborts the launch, counts toward burst.
|
|
if hookErr := runHooks(ctx, spec.PreStart, spec.PreStartTimeout, spec.Env, ew, EventPreStart); hookErr != nil {
|
|
spec.Logger.Warnf("supervisor: pre-start hook failed: %v", hookErr)
|
|
starts.record()
|
|
if exitOnBurst(starts, spec, ew, attempt) {
|
|
return ErrBurstExceeded
|
|
}
|
|
backoff = sleepBackoff(ctx, spec, ew, backoff, attempt+1)
|
|
if backoff < 0 {
|
|
return shutdown()
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Launch worker. Worker run-duration is measured in wall-clock time
|
|
// because the child is a real process; fake clocks (used in tests
|
|
// for backoff control) would otherwise report 0 and never trip the
|
|
// StableAfter threshold.
|
|
starts.record()
|
|
runStart := time.Now()
|
|
cmd := exec.Command(spec.Cmd, spec.Args...)
|
|
cmd.Env = spec.Env
|
|
cmd.Dir = spec.Dir
|
|
cmd.Stdout = spec.WorkerStdout
|
|
cmd.Stderr = spec.WorkerStderr
|
|
applyChildPgid(cmd)
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
spec.Logger.Errorf("supervisor: launch failed: %v", err)
|
|
_ = ew.emit(Event{
|
|
Event: EventExit,
|
|
Gen: curGen,
|
|
Attempt: attempt,
|
|
Reason: "launch_failed",
|
|
Error: err.Error(),
|
|
})
|
|
if exitOnBurst(starts, spec, ew, attempt) {
|
|
return ErrBurstExceeded
|
|
}
|
|
backoff = sleepBackoff(ctx, spec, ew, backoff, attempt+1)
|
|
if backoff < 0 {
|
|
return shutdown()
|
|
}
|
|
continue
|
|
}
|
|
|
|
pid := cmd.Process.Pid
|
|
spec.Logger.Infof("supervisor: worker started (pid=%d, gen=%d, attempt=%d)", pid, curGen, attempt)
|
|
_ = ew.emit(Event{Event: EventStart, PID: pid, Gen: curGen, Attempt: attempt})
|
|
|
|
// Graceful-shutdown goroutine: on ctx cancel, send SIGTERM then SIGKILL
|
|
// unless the worker exits within GracePeriod on its own.
|
|
stopped := make(chan struct{})
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
gracefulStop(cmd.Process, spec.GracePeriod, stopped)
|
|
case <-stopped:
|
|
}
|
|
}()
|
|
|
|
waitErr := cmd.Wait()
|
|
close(stopped)
|
|
runDur := time.Since(runStart)
|
|
|
|
exitCode, sigName, reason := classifyExit(cmd, waitErr, ctx.Err() != nil)
|
|
spec.Logger.Infof("supervisor: worker exited (pid=%d, gen=%d, dur=%s, code=%d, signal=%s, reason=%s)",
|
|
pid, curGen, runDur, exitCode, sigName, reason)
|
|
_ = ew.emit(Event{
|
|
Event: EventExit,
|
|
PID: pid,
|
|
Gen: curGen,
|
|
Attempt: attempt,
|
|
ExitCode: intPtr(exitCode),
|
|
Signal: sigName,
|
|
DurationMS: runDur.Milliseconds(),
|
|
Reason: reason,
|
|
})
|
|
|
|
// Stable: reset backoff if the worker stayed alive long enough.
|
|
if runDur >= spec.StableAfter {
|
|
if backoff > 0 {
|
|
_ = ew.emit(Event{
|
|
Event: EventStable, PID: pid, Gen: curGen, ResetBackoff: true,
|
|
DurationMS: runDur.Milliseconds(),
|
|
})
|
|
}
|
|
backoff = 0
|
|
}
|
|
|
|
// Post-exit hooks. Receive context env. Errors are logged, not fatal.
|
|
// Build hookEnv into a fresh slice so we never mutate spec.Env's
|
|
// underlying array (which `append(spec.Env, ...)` may do when
|
|
// cap > len).
|
|
hookEnv := make([]string, 0, len(spec.Env)+5)
|
|
hookEnv = append(hookEnv, spec.Env...)
|
|
hookEnv = append(hookEnv,
|
|
"WORKER_EXIT_CODE="+strconv.Itoa(exitCode),
|
|
"WORKER_SIGNAL="+sigName,
|
|
"WORKER_DURATION_MS="+strconv.FormatInt(runDur.Milliseconds(), 10),
|
|
"WORKER_PID="+strconv.Itoa(pid),
|
|
"WORKER_ATTEMPT="+strconv.Itoa(attempt),
|
|
)
|
|
// Post-exit hooks must run to completion even during shutdown so
|
|
// cleanup (iptables / nft / temp files) is not aborted. Use a
|
|
// detached ctx bounded by PostExitTimeout instead of ctx.
|
|
if hookErr := runHooks(context.Background(), spec.PostExit, spec.PostExitTimeout, hookEnv, ew, EventPostExit); hookErr != nil {
|
|
spec.Logger.Warnf("supervisor: post-exit hook failed: %v", hookErr)
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
return shutdown()
|
|
}
|
|
|
|
if exitOnBurst(starts, spec, ew, attempt) {
|
|
return ErrBurstExceeded
|
|
}
|
|
|
|
backoff = sleepBackoff(ctx, spec, ew, backoff, attempt+1)
|
|
if backoff < 0 {
|
|
return shutdown()
|
|
}
|
|
}
|
|
}
|
|
|
|
// sleepBackoff computes the next backoff, emits a backoff event, and sleeps.
|
|
// Returns the slept duration, or -1 if ctx was cancelled mid-sleep.
|
|
func sleepBackoff(ctx context.Context, spec Spec, ew *eventWriter, prev time.Duration, nextAttempt int) time.Duration {
|
|
d := nextBackoff(prev, spec.BackoffMin, spec.BackoffMax, *spec.BackoffJitter, defaultRNG)
|
|
_ = ew.emit(Event{Event: EventBackoff, SleepMS: d.Milliseconds(), NextAttempt: nextAttempt})
|
|
select {
|
|
case <-ctx.Done():
|
|
return -1
|
|
case <-spec.Clock.After(d):
|
|
}
|
|
return d
|
|
}
|
|
|
|
// exitOnBurst checks the burst tracker. Returns true if Run should bail out.
|
|
func exitOnBurst(b *burstTracker, spec Spec, ew *eventWriter, attempt int) bool {
|
|
if !b.exceeded() {
|
|
return false
|
|
}
|
|
_ = ew.emit(Event{
|
|
Event: EventBurstExit,
|
|
Attempts: b.count(),
|
|
Window: spec.BurstWindow.String(),
|
|
Attempt: attempt,
|
|
Reason: "crashloop budget exceeded",
|
|
})
|
|
return *spec.OnBurstExit
|
|
}
|
|
|
|
// classifyExit extracts the worker's exit code and (if killed) the signal
|
|
// name, plus a coarse reason string for the event log.
|
|
func classifyExit(cmd *exec.Cmd, waitErr error, ctxCancelled bool) (exitCode int, sigName, reason string) {
|
|
if cmd.ProcessState == nil {
|
|
return -1, "", "no_processstate"
|
|
}
|
|
exitCode = cmd.ProcessState.ExitCode()
|
|
if ws, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
|
|
sigName = ws.Signal().String()
|
|
}
|
|
switch {
|
|
case ctxCancelled:
|
|
reason = "shutdown"
|
|
case waitErr == nil:
|
|
reason = "exited"
|
|
case sigName != "":
|
|
reason = "signaled"
|
|
default:
|
|
reason = "crashed"
|
|
}
|
|
return
|
|
}
|
|
|
|
// runHooks invokes each hook sequentially. The first non-zero exit (or
|
|
// timeout / launch error) is recorded and returned; subsequent hooks still
|
|
// run so cleanup paths complete.
|
|
func runHooks(ctx context.Context, hooks []Hook, timeout time.Duration, env []string, ew *eventWriter, kind string) error {
|
|
var firstErr error
|
|
for _, h := range hooks {
|
|
if len(h.Argv) == 0 {
|
|
continue
|
|
}
|
|
hctx, cancel := context.WithTimeout(ctx, timeout)
|
|
cmd := exec.CommandContext(hctx, h.Argv[0], h.Argv[1:]...)
|
|
cmd.Env = env
|
|
start := time.Now()
|
|
err := cmd.Run()
|
|
dur := time.Since(start)
|
|
cancel()
|
|
|
|
ev := Event{
|
|
Event: kind,
|
|
Hook: h.Argv[0],
|
|
DurationMS: dur.Milliseconds(),
|
|
}
|
|
exitCode := 0
|
|
if err != nil {
|
|
ev.Error = err.Error()
|
|
if cmd.ProcessState != nil {
|
|
exitCode = cmd.ProcessState.ExitCode()
|
|
} else {
|
|
exitCode = -1
|
|
}
|
|
} else if cmd.ProcessState != nil {
|
|
exitCode = cmd.ProcessState.ExitCode()
|
|
}
|
|
ev.ExitCode = intPtr(exitCode)
|
|
_ = ew.emit(ev)
|
|
|
|
if err != nil && firstErr == nil {
|
|
firstErr = fmt.Errorf("%s hook %q: %w", kind, h.Argv[0], err)
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|