feat(task): 任务执行模型(后台声明/前台2s转后台/bus task.done 回输出/实时输出读取/Abort 防失控)
This commit is contained in:
1 parent
430fc4666b
commit
ebeecc7a32
5 files changed
+1160
No files matched your search
@@ -0,0 +1,284 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
|
||||
)
|
||||
|
||||
// mockProvider is a configurable exec.Provider. It mocks the interface, not a
|
||||
// concrete type, per the test conventions.
|
||||
type mockProvider struct {
|
||||
execute func(ctx context.Context, req exec.ExecRequest) (*exec.ExecResult, error)
|
||||
}
|
||||
|
||||
func (m mockProvider) Execute(ctx context.Context, req exec.ExecRequest) (*exec.ExecResult, error) {
|
||||
if m.execute == nil {
|
||||
return &exec.ExecResult{}, nil
|
||||
}
|
||||
return m.execute(ctx, req)
|
||||
}
|
||||
|
||||
// mockRun returns a provider that sleeps (unless sleep==0) and then returns the
|
||||
// given output or error. It honors ctx cancellation so Abort releases it.
|
||||
func mockRun(out string, err error, sleep time.Duration) mockProvider {
|
||||
return mockProvider{execute: func(ctx context.Context, req exec.ExecRequest) (*exec.ExecResult, error) {
|
||||
if sleep > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(sleep):
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &exec.ExecResult{Stdout: out, ExitCode: 0}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return logger.New(logger.Options{Level: "error", Out: io.Discard})
|
||||
}
|
||||
|
||||
func newTestManager(t *testing.T, p exec.Provider) *Manager {
|
||||
t.Helper()
|
||||
m, err := New(&config.Config{}, p, discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.Close() })
|
||||
return m
|
||||
}
|
||||
|
||||
func subscribeDone(t *testing.T, m *Manager) (<-chan DoneEvent, func()) {
|
||||
t.Helper()
|
||||
ch := make(chan DoneEvent, 16)
|
||||
unsub, err := m.Bus().Subscribe(context.Background(), "task.done", func(_ context.Context, e bus.Event) error {
|
||||
if de, ok := e.Payload.(DoneEvent); ok {
|
||||
ch <- de
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe task.done: %v", err)
|
||||
}
|
||||
return ch, unsub
|
||||
}
|
||||
|
||||
// waitTerminal polls a task until it reaches a terminal status or the timeout.
|
||||
func waitTerminal(tk *Task, timeout time.Duration) (Snapshot, bool) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if s := tk.Snapshot(); s.Status.isTerminal() {
|
||||
return s, true
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
return tk.Snapshot(), false
|
||||
}
|
||||
|
||||
func waitActive(t *testing.T, m *Manager, want int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if m.Active() == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Active()=%d, want %d", m.Active(), want)
|
||||
}
|
||||
|
||||
// TestManager_New_Validates checks the constructor's nil guards.
|
||||
func TestManager_New_Validates(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := New(&config.Config{}, nil, discardLogger()); !errors.Is(err, ErrNilProvider) {
|
||||
t.Errorf("nil provider: err=%v, want ErrNilProvider", err)
|
||||
}
|
||||
if _, err := New(&config.Config{}, mockProvider{}, nil); !errors.Is(err, ErrNilLogger) {
|
||||
t.Errorf("nil logger: err=%v, want ErrNilLogger", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_BackgroundTask_RealTimeOutput verifies a background task returns
|
||||
// immediately, that Output() is readable (empty while running) during the run
|
||||
// and populated after, and that ReadOutput returns the final output.
|
||||
func TestManager_BackgroundTask_RealTimeOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := newTestManager(t, mockRun("hello world", nil, 400*time.Millisecond))
|
||||
|
||||
tk, err := m.Start(context.Background(), TaskSpec{Command: []string{"echo", "hi"}, Background: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if tk.Kind != KindBackground {
|
||||
t.Errorf("Kind=%s, want background", tk.Kind)
|
||||
}
|
||||
if got := tk.Output().Stdout; got != "" {
|
||||
t.Errorf("output during run=%q, want empty (still running)", got)
|
||||
}
|
||||
|
||||
out, err := tk.ReadOutput(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ReadOutput: %v", err)
|
||||
}
|
||||
if out.Stdout != "hello world" {
|
||||
t.Errorf("Stdout=%q, want hello world", out.Stdout)
|
||||
}
|
||||
if snap := tk.Snapshot(); snap.Status != StatusDone {
|
||||
t.Errorf("Status=%s, want done", snap.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_TaskOutputAndDoneEvents verifies both bus subjects fire, in order,
|
||||
// with the expected payloads.
|
||||
func TestManager_TaskOutputAndDoneEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := newTestManager(t, mockRun("hi out", nil, 0))
|
||||
|
||||
ch := make(chan bus.Event, 16)
|
||||
subCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
unsub, err := m.Bus().Subscribe(subCtx, "task.>", func(_ context.Context, e bus.Event) error {
|
||||
ch <- e
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe task.>: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
tk, err := m.Start(context.Background(), TaskSpec{Command: []string{"echo"}, Background: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
var gotOutput, gotDone bool
|
||||
timeout := time.After(2 * time.Second)
|
||||
for !gotDone {
|
||||
select {
|
||||
case e := <-ch:
|
||||
switch e.Subject {
|
||||
case "task.output":
|
||||
gotOutput = true
|
||||
if oe := e.Payload.(OutputEvent); oe.Output.Stdout != "hi out" {
|
||||
t.Errorf("output event Stdout=%q", oe.Output.Stdout)
|
||||
}
|
||||
case "task.done":
|
||||
gotDone = true
|
||||
de := e.Payload.(DoneEvent)
|
||||
if de.ID != tk.ID {
|
||||
t.Errorf("done ID=%q, want %q", de.ID, tk.ID)
|
||||
}
|
||||
if de.Status != StatusDone {
|
||||
t.Errorf("done Status=%s, want done", de.Status)
|
||||
}
|
||||
if de.Output.Stdout != "hi out" {
|
||||
t.Errorf("done Stdout=%q, want hi out", de.Output.Stdout)
|
||||
}
|
||||
}
|
||||
case <-timeout:
|
||||
t.Fatal("timeout waiting for bus events")
|
||||
}
|
||||
}
|
||||
if !gotOutput {
|
||||
t.Errorf("task.output event not received")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_ForegroundTask_FastCompletes verifies a foreground task that
|
||||
// finishes inside the threshold is returned already done.
|
||||
func TestManager_ForegroundTask_FastCompletes(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := newTestManager(t, mockRun("fast out", nil, 0))
|
||||
|
||||
start := time.Now()
|
||||
tk, err := m.Start(context.Background(), TaskSpec{Command: []string{"echo", "fast"}, Background: false})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 1500*time.Millisecond {
|
||||
t.Errorf("Start took %v, want < 1.5s", elapsed)
|
||||
}
|
||||
if tk.Kind != KindForeground {
|
||||
t.Errorf("Kind=%s, want foreground", tk.Kind)
|
||||
}
|
||||
snap := tk.Snapshot()
|
||||
if snap.Status != StatusDone {
|
||||
t.Errorf("Status=%s, want done", snap.Status)
|
||||
}
|
||||
if snap.Output.Stdout != "fast out" {
|
||||
t.Errorf("Stdout=%q, want fast out", snap.Output.Stdout)
|
||||
}
|
||||
if !snap.FinishedAt.After(snap.StartedAt) {
|
||||
t.Errorf("FinishedAt=%v should be after StartedAt=%v", snap.FinishedAt, snap.StartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_ForegroundTask_PromotedAfterThreshold verifies a foreground task
|
||||
// that outlives the threshold is promoted: Start returns early with Kind=background
|
||||
// and Status=running, and the task completes in the background.
|
||||
func TestManager_ForegroundTask_PromotedAfterThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := newTestManager(t, mockRun("slow out", nil, 3*time.Second))
|
||||
|
||||
start := time.Now()
|
||||
tk, err := m.Start(context.Background(), TaskSpec{Command: []string{"echo", "slow"}, Background: false})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed >= 3*time.Second {
|
||||
t.Errorf("Start waited %v; should promote before completion", elapsed)
|
||||
}
|
||||
if tk.Kind != KindBackground {
|
||||
t.Errorf("Kind=%s, want background after promotion", tk.Kind)
|
||||
}
|
||||
if tk.Status() != StatusRunning {
|
||||
t.Errorf("Status=%s, want running", tk.Status())
|
||||
}
|
||||
|
||||
snap, ok := waitTerminal(tk, 4*time.Second)
|
||||
if !ok {
|
||||
t.Fatalf("task did not finish after promotion, status=%s", tk.Status())
|
||||
}
|
||||
if snap.Status != StatusDone {
|
||||
t.Errorf("Status=%s, want done", snap.Status)
|
||||
}
|
||||
if snap.Output.Stdout != "slow out" {
|
||||
t.Errorf("Stdout=%q, want slow out", snap.Output.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_Foreground_ParentContextCancelAborts verifies a foreground Start is
|
||||
// aborted with the parent context's error when that context is canceled.
|
||||
func TestManager_Foreground_ParentContextCancelAborts(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := newTestManager(t, mockRun("hang", nil, 5*time.Second))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
_, err := m.Start(ctx, TaskSpec{Command: []string{"hang"}, Background: false})
|
||||
if err == nil {
|
||||
t.Fatal("Start: want error on parent context cancel")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("err=%v, want context.Canceled", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("Start took %v, want < 2s", elapsed)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user