Files
kandev/apps/backend/internal/plugins/runtime/manager_test.go
T
João Salavisa 279e15b318 feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/SendMessage) (#1970)
* feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/CreateComment)

The api_write manifest capability was a stored no-op. Wire the deferred
phase-2 write RPCs (ADR 0043) so closed-loop plugins (Sentry->task,
Slack slash-command task creation, refactor fan-out) can create and
update tasks and post comments.

- SDK: Host.Tasks() gains Create/Update; new Host.Comments() writer;
  CreateTaskInput/UpdateTaskInput/Comment DTOs (pkg/pluginsdk).
- Gating: api_write:tasks gates Create/UpdateTask, api_write:comments
  gates CreateComment; undeclared -> gRPC PermissionDenied, per-method
  and independent of the read caps on the same resource.
- Writes route through the first-party service layer (task service for
  tasks, office service for comments) via backendapp adapters, never a
  repository -- so task.*/comment-created events fire and WS updates.
  Server stamps source="plugin:<id>"; CreateTask resolves workspace/
  workflow defaults and best-effort honors start_agent; UpdateTask uses
  a conservative title/description/state/workflow_step_id mask.
- Fixture plugins exercise a CreateTask+CreateComment round-trip;
  in-process, real-broker wire, and subprocess tests cover the round-trip
  and per-capability denial.
- Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments moved
  writes deferred -> implemented; ADR 0043 amended with the concrete
  service-layer routing decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): send a message to a task session via Host SendMessage (replaces CreateComment)

The office TaskComment write was the wrong primitive — it's an office/
dashboard construct, not a way to message the agent working a task, and
it only did anything under office reactivity. Replace the CreateComment
write RPC with SendMessage, which delivers a prompt to a task session
through the orchestrator's real delivery path (the same one message_task
uses).

- Proto: swap CreateComment/Comment for SendMessage(SendMessageRequest)
  returns SendMessageResponse{session_id, status}; regenerated stubs.
- SDK: Host.Comments() removed; Host.Messages().Send(taskID, sessionID,
  text) added (pairs with Messages().List), returning MessageDispatch.
- Gating: api_write:comments dropped; SendMessage gated api_write:messages
  (independent of api_read:messages on the same accessor, like Tasks()).
- Delivery: a new pluginsTaskMessengerAdapter (backendapp) resolves the
  target session (explicit id verified against the task, or the primary
  session), records a user message stamped source="plugin:<id>", then
  dispatches by state — running → queue; idle/completed → prompt (resume
  if the agent process is gone); never-started → launch; failed dispatch
  deletes the recorded message. Wired via SetWriteDeps (live-read).
- Tests: adapter state-machine unit tests (queue/start/prompt/resume/
  delete-on-failure/belongs-to-task/no-primary/terminal); SDK, in-process
  gating, wire, and subprocess round-trips updated to SendMessage.
- Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments, and the
  ADR 0043 amendment updated from comment-create to message-send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): address PR review — nil guards, detached contexts, state validation

Follow-up fixes from the #1970 review (Claude + CodeRabbit bots):

- grpcHostServer.CreateTask/UpdateTask/SendMessage: nil-guard the returned
  value before toProto() so a custom/test Host returning (nil, nil) yields a
  gRPC Internal error, not a server panic (matches GetTask's pattern).
- host.go: fix stale server doc comment (Comments()/CreateComment →
  Messages()/SendMessage).
- startTaskBestEffort and the post-resume wait+retry now run on
  context.WithoutCancel(ctx): the task/resume is already committed, so a
  plugin's incoming deadline must not silently abort the launch/resume.
- pluginsTaskWriterAdapter.UpdateTask validates the state string against the
  known task-state enum (excluding orchestrator-owned SCHEDULING) and returns
  InvalidArgument, so a plugin typo can't forward a bogus state to the service.
- Tests: add the resume-wait-timeout path (message deleted so a retry can't
  duplicate), and a dedicated pluginsTaskWriterAdapter test covering
  source→metadata mapping and state validation (both adapters now depend on
  narrow interfaces for testability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): launch start_agent asynchronously so CreateTask doesn't block

Review nit follow-up: startTaskBestEffort called StartTask synchronously, so
with the detached context the plugin's CreateTask RPC blocked on the
orchestrator's full inline launch path (office lookup, state update, workflow
step move, task fetch) with no bound — and the "asynchronous" comment was wrong.

Move the fire-and-forget into pluginsTaskStarterAdapter (backendapp, no goleak),
mirroring MCP's launchAutoStartTask: the launch runs in a goroutine on a
context.WithTimeout(context.WithoutCancel(ctx), AgentLaunchTimeout), and
StartTask returns immediately. The plugin host stays a simple synchronous call
(its taskStarter contract now documents "returns promptly, launches async").

Adds adapters_plugin_starter_test.go: launch invoked, returns before the launch
completes, detaches from a cancelled caller context, and swallows launch errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plugins): document write validation + SendMessage rollback; harden starter test cleanup

Addresses the final PR #1970 review nits:
- adapters_plugin_starter_test.go: register the block-channel release via
  t.Cleanup (sync.Once-guarded) before the goroutine can block, per AGENTS.md.
- GRPC-CONTRACT.md: document CreateTask/UpdateTask validation + error rules
  (title required, id required, NotFound on missing task, invalid-state reject).
- spec.md: document that a failed SendMessage dispatch deletes the recorded
  message, plus the required-field/NotFound/terminal-session rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:54:12 +01:00

547 lines
19 KiB
Go

package runtime
import (
"context"
"errors"
"os"
"path/filepath"
goruntime "runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/store"
"github.com/kandev/kandev/pkg/pluginsdk"
)
// fakeHost is a minimal in-memory pluginsdk.Host for end-to-end tests: it
// only implements SetState (with a channel so tests can synchronize on
// delivery without a sleep), and errors on everything else. It embeds
// UnimplementedHostData to satisfy the Host data API (ADR 0043)
// sub-accessors without wiring them; see docs/plans/plugins/host-data-api/
// task-04-host-data-impl.md for the real implementation.
type fakeHost struct {
pluginsdk.UnimplementedHostData
mu sync.Mutex
states map[string]map[string]any
setCh chan struct{}
// Host data API write round-trip recording (ADR 0043 phase 2). writeCh
// signals a CreateTask arrived so tests can synchronize without a sleep;
// createdTitle/lastSendText capture what the plugin sent over the real
// subprocess transport.
writeCh chan struct{}
createdTitle string
lastSendTask string
lastSendText string
}
func newFakeHost() *fakeHost {
return &fakeHost{states: map[string]map[string]any{}, setCh: make(chan struct{}, 16), writeCh: make(chan struct{}, 16)}
}
// Tasks/Messages override UnimplementedHostData so the fixture's write probe
// (CreateTask + SendMessage) round-trips to this fake host over the real
// subprocess gRPC transport. This fake does not enforce capabilities — that is
// covered against a real *pluginHost in internal/plugins' wire tests; here we
// only prove the write RPC reaches the host and its result returns.
func (h *fakeHost) Tasks() pluginsdk.TaskReader { return fakeHostTaskReader{h} }
func (h *fakeHost) Messages() pluginsdk.MessageReader { return fakeHostMessageReader{h} }
type fakeHostTaskReader struct{ h *fakeHost }
func (fakeHostTaskReader) List(context.Context, pluginsdk.TaskFilter, pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) {
return nil, nil, nil
}
func (fakeHostTaskReader) Get(context.Context, string) (*pluginsdk.Task, error) { return nil, nil }
func (fakeHostTaskReader) Update(context.Context, pluginsdk.UpdateTaskInput) (*pluginsdk.Task, error) {
return nil, nil
}
func (r fakeHostTaskReader) Create(_ context.Context, in pluginsdk.CreateTaskInput) (*pluginsdk.Task, error) {
r.h.mu.Lock()
r.h.createdTitle = in.Title
r.h.mu.Unlock()
select {
case r.h.writeCh <- struct{}{}:
default:
}
return &pluginsdk.Task{ID: "task-from-host", Title: in.Title, WorkspaceID: in.WorkspaceID, WorkflowID: in.WorkflowID}, nil
}
type fakeHostMessageReader struct{ h *fakeHost }
func (fakeHostMessageReader) List(context.Context, pluginsdk.MessageFilter, pluginsdk.Page) ([]pluginsdk.Message, *pluginsdk.PageInfo, error) {
return nil, nil, nil
}
func (r fakeHostMessageReader) Send(_ context.Context, taskID, sessionID, text string) (*pluginsdk.MessageDispatch, error) {
r.h.mu.Lock()
r.h.lastSendTask = taskID
r.h.lastSendText = text
r.h.mu.Unlock()
return &pluginsdk.MessageDispatch{SessionID: "session-from-host", Status: "queued"}, nil
}
func (h *fakeHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) {
return nil, false, nil
}
func (h *fakeHost) SetState(_ context.Context, scope, scopeID, key string, value map[string]any) error {
h.mu.Lock()
h.states[scope+"|"+scopeID+"|"+key] = value
h.mu.Unlock()
select {
case h.setCh <- struct{}{}:
default:
}
return nil
}
func (h *fakeHost) DeleteState(context.Context, string, string, string) error { return nil }
func (h *fakeHost) ListState(context.Context, string, string) ([]pluginsdk.StateEntry, error) {
return nil, nil
}
func (h *fakeHost) GetConfig(context.Context) (map[string]any, error) { return nil, nil }
func (h *fakeHost) GetSecret(context.Context, string) (string, bool, error) { return "", false, nil }
func (h *fakeHost) SetSecret(context.Context, string, string) error { return nil }
func (h *fakeHost) DeleteSecret(context.Context, string) error { return nil }
func (h *fakeHost) RevealSecret(context.Context, string) (string, error) { return "", nil }
func (h *fakeHost) EmitEvent(context.Context, string, map[string]any) error { return nil }
func (h *fakeHost) get(scope, scopeID, key string) (map[string]any, bool) {
h.mu.Lock()
defer h.mu.Unlock()
v, ok := h.states[scope+"|"+scopeID+"|"+key]
return v, ok
}
func (h *fakeHost) writeProbe() (title, sendTask, sendText string) {
h.mu.Lock()
defer h.mu.Unlock()
return h.createdTitle, h.lastSendTask, h.lastSendText
}
// buildFixtureRecord copies the real fixture plugin binary into a fresh
// InstallPath and returns a *store.Record ready for Manager.Start,
// skipping the test if -short suppressed the fixture build.
func buildFixtureRecord(t *testing.T, id string) *store.Record {
t.Helper()
if fixtureBinPath == "" {
t.Skip("fixture plugin binary not built (test run with -short)")
}
installPath := t.TempDir()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
relExec := filepath.Join("server", "plugin-"+platformKey)
destExec := filepath.Join(installPath, relExec)
if err := os.MkdirAll(filepath.Dir(destExec), 0o755); err != nil {
t.Fatalf("mkdir server dir: %v", err)
}
copyFile(t, fixtureBinPath, destExec, 0o755)
return &store.Record{
Manifest: manifest.Manifest{
ID: id,
APIVersion: 1,
Version: "1.0.0",
Runtime: manifest.Runtime{
Type: "binary",
Executables: map[string]string{platformKey: relExec},
},
},
Status: store.StatusRegistered,
InstallPath: installPath,
}
}
func copyFile(t *testing.T, src, dst string, mode os.FileMode) {
t.Helper()
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read %s: %v", src, err)
}
if err := os.WriteFile(dst, data, mode); err != nil {
t.Fatalf("write %s: %v", dst, err)
}
}
func TestManager_StartDeliverEventWebhookStop(t *testing.T) {
rec := buildFixtureRecord(t, "kandev-fixture-plugin")
pluginsDir := t.TempDir()
host := newFakeHost()
m := NewManager(pluginsDir, nil, testLogger(t))
t.Cleanup(m.StopAll)
ctx := context.Background()
if err := m.Start(ctx, rec, func(string) pluginsdk.Host { return host }); err != nil {
t.Fatalf("Start() unexpected error: %v", err)
}
if !m.Running(rec.ID) {
t.Fatal("Running() = false right after a successful Start()")
}
remote, ok := m.Get(rec.ID)
if !ok {
t.Fatal("Get() ok = false right after a successful Start()")
}
t.Run("HandleWebhook echoes the body over the real subprocess", func(t *testing.T) {
resp, err := remote.HandleWebhook(ctx, &pluginsdk.WebhookRequest{WebhookKey: "echo", Body: []byte("hi")})
if err != nil {
t.Fatalf("HandleWebhook() unexpected error: %v", err)
}
if string(resp.Body) != "hi" {
t.Fatalf("HandleWebhook().Body = %q, want echoed body", resp.Body)
}
})
t.Run("DeliverEvent reaches the plugin, which calls back into Host.SetState", func(t *testing.T) {
require.Eventually(t, func() bool {
if err := remote.DeliverEvent(ctx, &pluginsdk.Event{
EventID: "e1", EventType: "task.created",
}); err != nil {
return false
}
select {
case <-host.setCh:
return true
default:
return false
}
}, 5*time.Second, 20*time.Millisecond,
"plugin should call Host.SetState after asynchronous Host injection completes")
value, found := host.get("instance", "", "last_event")
if !found {
t.Fatal("fakeHost never recorded the delivered event")
}
if value["event_type"] != "task.created" {
t.Fatalf("recorded event_type = %v, want %q", value["event_type"], "task.created")
}
})
t.Run("HandleWebhook write probe round-trips CreateTask/SendMessage to Host", func(t *testing.T) {
resp, err := remote.HandleWebhook(ctx, &pluginsdk.WebhookRequest{WebhookKey: "write"})
if err != nil {
t.Fatalf("HandleWebhook(write) unexpected error: %v", err)
}
if string(resp.Body) != "ok" {
t.Fatalf("HandleWebhook(write).Body = %q, want ok", resp.Body)
}
select {
case <-host.writeCh:
case <-time.After(5 * time.Second):
t.Fatal("plugin never called Host.Tasks().Create over the subprocess transport")
}
title, sendTask, sendText := host.writeProbe()
if title != "fixture write probe" {
t.Fatalf("recorded created task title = %q, want %q", title, "fixture write probe")
}
// The message send targets the task id the host returned from Create,
// proving the two write RPCs chain over the real transport.
if sendTask != "task-from-host" || sendText != "fixture probe message" {
t.Fatalf("recorded message = (task %q, text %q), want (task-from-host, fixture probe message)", sendTask, sendText)
}
})
t.Run("Ping succeeds against the live subprocess", func(t *testing.T) {
if err := m.Ping(rec.ID); err != nil {
t.Fatalf("Ping() unexpected error: %v", err)
}
})
m.Stop(rec.ID)
if m.Running(rec.ID) {
t.Fatal("Running() = true after Stop()")
}
}
func TestManager_CrashTriggersAutomaticRestart(t *testing.T) {
rec := buildFixtureRecord(t, "kandev-fixture-plugin-crash")
pluginsDir := t.TempDir()
host := newFakeHost()
var mu sync.Mutex
var transitions []bool
onStatusChange := func(_ string, healthy bool) {
mu.Lock()
transitions = append(transitions, healthy)
mu.Unlock()
}
m := NewManager(pluginsDir, onStatusChange, testLogger(t),
WithPingInterval(20*time.Millisecond),
WithMaxConsecutiveFailures(1),
WithRestartBackoff([]time.Duration{10 * time.Millisecond}),
WithMaxRestartAttempts(3),
)
t.Cleanup(m.StopAll)
ctx := context.Background()
if err := m.Start(ctx, rec, func(string) pluginsdk.Host { return host }); err != nil {
t.Fatalf("Start() unexpected error: %v", err)
}
remote, ok := m.Get(rec.ID)
if !ok {
t.Fatal("Get() ok = false right after Start()")
}
// Invoking the "crash" webhook exits the subprocess immediately; the RPC
// call itself is expected to error (connection dropped mid-call) or
// hang up cleanly - either way we only assert on the recovery below.
_, _ = remote.HandleWebhook(ctx, &pluginsdk.WebhookRequest{WebhookKey: "crash"})
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(transitions) >= 2 && !transitions[0] && transitions[1]
}, 10*time.Second, 20*time.Millisecond,
"onStatusChange should observe degraded (false) then recovered (true) after the crash")
}
// TestManager_ConcurrentStartOnlySpawnsOnce pins the fix for the TOCTOU
// double-spawn race: two Start calls for the same plugin id racing the
// "already running" check must not both reach spawnFn. The first goroutine
// is deliberately paused *inside* its (slow) spawn — the exact window the
// old code released the manager lock across — before the second goroutine's
// startProcess call is allowed to race in.
func TestManager_ConcurrentStartOnlySpawnsOnce(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t))
t.Cleanup(m.StopAll)
var spawnCalls int32
firstEntered := make(chan struct{})
release := make(chan struct{})
var closeOnce sync.Once
spawnFn := func() (spawnedProcess, error) {
if atomic.AddInt32(&spawnCalls, 1) == 1 {
closeOnce.Do(func() { close(firstEntered) })
<-release // hold the first spawn open so the second Start can race in
}
return &fakeSpawnedProcess{}, nil
}
var wg sync.WaitGroup
errs := make([]error, 2)
wg.Add(1)
go func() {
defer wg.Done()
errs[0] = m.startProcess("race-plugin", spawnFn)
}()
<-firstEntered // the first Start is now mid-spawn, before it has registered anything
errs[1] = m.startProcess("race-plugin", spawnFn)
close(release)
wg.Wait()
if got := atomic.LoadInt32(&spawnCalls); got != 1 {
t.Fatalf("spawnFn called %d times for a concurrent Start of the same id, want exactly 1 (no double-spawn)", got)
}
if errs[0] != nil {
t.Fatalf("first startProcess() unexpected error: %v", errs[0])
}
if errs[1] == nil {
t.Fatal("second concurrent startProcess() for the same id succeeded, want an error (already starting)")
}
if !m.Running("race-plugin") {
t.Fatal("Running() = false after the winning Start() completed")
}
}
// TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess pins the fix for
// the Stop-during-Start orphan race: Stop previously only consulted
// m.processes, so a Disable/Uninstall racing an in-flight Start was a
// silent no-op — the spawn then completed and inserted a live supervised
// process for a plugin whose record/files may already be gone by the time
// the spawn finished. claimStart's "starting" reservation now doubles as
// the hook Stop uses to request an abort: startProcess checks it right
// after the (slow) spawn succeeds and, if set, kills the freshly spawned
// process and never inserts it into m.processes.
func TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t))
t.Cleanup(m.StopAll)
entered := make(chan struct{})
release := make(chan struct{})
var closeOnce sync.Once
spawned := &fakeSpawnedProcess{}
spawnFn := func() (spawnedProcess, error) {
closeOnce.Do(func() { close(entered) })
<-release // hold the spawn open until the test has called Stop
return spawned, nil
}
errCh := make(chan error, 1)
go func() {
errCh <- m.startProcess("racing-plugin", spawnFn)
}()
<-entered // the spawn is in flight, before startProcess has registered anything
m.Stop("racing-plugin") // races the in-flight Start: must not be a no-op
close(release) // let the spawn finish
err := <-errCh
if err == nil {
t.Fatal("startProcess() expected an error when Stop() raced an in-flight Start, got nil")
}
if m.Running("racing-plugin") {
t.Fatal("Running() = true after Stop() raced an in-flight Start; an orphan process was left registered")
}
if !spawned.isKilled() {
t.Fatal("the spawned process was never killed after Stop() raced an in-flight Start (orphan subprocess)")
}
}
// TestManager_StopAllDuringStartAbortsAndLeavesNoOrphanProcess is the same
// race as TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess, but
// through StopAll (graceful shutdown), which previously only iterated
// m.processes too.
func TestManager_StopAllDuringStartAbortsAndLeavesNoOrphanProcess(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t))
t.Cleanup(m.StopAll)
entered := make(chan struct{})
release := make(chan struct{})
var closeOnce sync.Once
spawned := &fakeSpawnedProcess{}
spawnFn := func() (spawnedProcess, error) {
closeOnce.Do(func() { close(entered) })
<-release
return spawned, nil
}
errCh := make(chan error, 1)
go func() {
errCh <- m.startProcess("racing-plugin-2", spawnFn)
}()
<-entered
m.StopAll()
close(release)
err := <-errCh
if err == nil {
t.Fatal("startProcess() expected an error when StopAll() raced an in-flight Start, got nil")
}
if m.Running("racing-plugin-2") {
t.Fatal("Running() = true after StopAll() raced an in-flight Start; an orphan process was left registered")
}
if !spawned.isKilled() {
t.Fatal("the spawned process was never killed after StopAll() raced an in-flight Start (orphan subprocess)")
}
}
// TestManager_StartAfterExhaustionRespawns pins the fix that lets a fresh
// Start cleanly replace an entry whose restart attempts were exhausted
// (gaveUp==true, current==nil): before the fix, Start saw the stale map
// entry and always errored "already running" instead of respawning.
func TestManager_StartAfterExhaustionRespawns(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t),
WithPingInterval(5*time.Millisecond),
WithMaxConsecutiveFailures(1),
WithRestartBackoff([]time.Duration{0}),
WithMaxRestartAttempts(1),
)
t.Cleanup(m.StopAll)
var mu sync.Mutex
firstSpawnDone := false
spawnFn := func() (spawnedProcess, error) {
mu.Lock()
defer mu.Unlock()
if !firstSpawnDone {
firstSpawnDone = true
return &fakeSpawnedProcess{exited: true}, nil // dies immediately, forcing an exhausting restart loop
}
return nil, errors.New("respawn always fails") // every restart attempt fails too
}
if err := m.startProcess("exhaust-plugin", spawnFn); err != nil {
t.Fatalf("startProcess() unexpected error: %v", err)
}
// Wait for the supervision loop to actually finish giving up (gaveUp),
// not just for current to go nil — current is cleared at the start of
// handleFailureAndRestart, before the (failing) respawn attempt(s) run,
// so polling only Running()==false races the loop's own completion.
require.Eventually(t, func() bool {
m.mu.Lock()
p, ok := m.processes["exhaust-plugin"]
m.mu.Unlock()
return ok && p.isGaveUp()
}, 2*time.Second, 5*time.Millisecond,
"plugin's process should have given up once restart attempts are exhausted")
healthy := &fakeSpawnedProcess{}
respawnCalls := 0
freshSpawnFn := func() (spawnedProcess, error) {
respawnCalls++
return healthy, nil
}
if err := m.startProcess("exhaust-plugin", freshSpawnFn); err != nil {
t.Fatalf("startProcess() after exhaustion unexpected error: %v", err)
}
if respawnCalls != 1 {
t.Fatalf("respawn spawnFn called %d times, want 1", respawnCalls)
}
if !m.Running("exhaust-plugin") {
t.Fatal("Running() = false after a fresh Start() following exhaustion")
}
}
// TestManager_DefaultStartTimeoutIs30s pins the fix for bounded startup:
// Manager.Start previously spawned via a hcplugin.Client with no configured
// StartTimeout, defaulting to go-plugin's own 1-minute handshake timeout, so
// a hung plugin binary could block Enable/Install for up to a minute. A
// fresh Manager must default to a much shorter, explicit bound.
func TestManager_DefaultStartTimeoutIs30s(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t))
if m.startTimeout != 30*time.Second {
t.Fatalf("default startTimeout = %v, want 30s", m.startTimeout)
}
}
// TestManager_WithStartTimeoutOverridesDefault pins the escape hatch for
// callers (and this test file) that need a different bound.
func TestManager_WithStartTimeoutOverridesDefault(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t), WithStartTimeout(5*time.Second))
if m.startTimeout != 5*time.Second {
t.Fatalf("startTimeout = %v, want 5s (WithStartTimeout override)", m.startTimeout)
}
}
// TestManager_StartFailsFastOnHandshakeTimeout proves the configured
// startTimeout actually reaches the underlying hcplugin.Client: an
// unreasonably short timeout must make Start() return an error quickly
// instead of ever completing the handshake, even against a real, working
// fixture binary.
func TestManager_StartFailsFastOnHandshakeTimeout(t *testing.T) {
rec := buildFixtureRecord(t, "kandev-fixture-plugin-timeout")
m := NewManager(t.TempDir(), nil, testLogger(t), WithStartTimeout(1*time.Nanosecond))
t.Cleanup(m.StopAll)
started := time.Now()
err := m.Start(context.Background(), rec, func(string) pluginsdk.Host { return newFakeHost() })
elapsed := time.Since(started)
if err == nil {
t.Fatal("Start() with a 1ns startTimeout unexpectedly succeeded")
}
if elapsed > 10*time.Second {
t.Fatalf("Start() took %v to fail, want well under go-plugin's 1-minute default handshake timeout", elapsed)
}
}