···317317 _ = s.conn.SetReadDeadline(time.Time{})
318318 }
319319320320+ // a blocked vsock read wont wake up just from the ctx being cancelled,
321321+ // only a deadline will wake it up, so if the VM crashes mid-step the read would
322322+ // hang until workflow timeout. so we will set a deadline in the past to cancel it.
323323+ //
324324+ // we set a deadline here instead of closing the connection, this is the long-lived
325325+ // connection that everything reuses, so we only really want to interrupt it for this
326326+ // current read. this also lands as a timeout error which the netErr.Timeout() check
327327+ // below maps to ctx.Err() correctly
328328+ stop := context.AfterFunc(ctx, func() {
329329+ _ = s.conn.SetReadDeadline(time.Now())
330330+ })
331331+ defer stop()
332332+320333 msg, err := s.dec.Decode()
321334 if err != nil {
322335 var netErr net.Error
+32-7
spindle/engines/microvm/engine.go
···1010 "os"
1111 "slices"
1212 "sync"
1313+ "sync/atomic"
1314 "time"
14151516 "gopkg.in/yaml.v3"
···281282 return fmt.Errorf("microVM workflow is not connected to agent")
282283 }
283284285285+ stderr := wfLogger.DataWriter(idx, "stderr")
286286+287287+ execCtx, vmExited, cancelWatch := watchVMExit(ctx, state.VM)
288288+ defer cancelWatch()
289289+284290 step := w.Steps[idx]
285291 if s, ok := step.(Step); ok && s.action == activationStepAction {
286286- return e.activateConfig(ctx, wid, state, s, wfLogger.DataWriter(idx, "stdout"))
292292+ err := e.activateConfig(execCtx, wid, state, s, wfLogger.DataWriter(idx, "stdout"))
293293+ return e.classifyStepError(ctx, state, stderr, vmExited, err)
287294 }
288295 env := []string{
289296 "HOME=/workspace",
···304311 }
305312306313 stdout := wfLogger.DataWriter(idx, "stdout")
307307- stderr := wfLogger.DataWriter(idx, "stderr")
308308- exitCode, err := state.Agent.Exec(ctx, AgentExec{
314314+ exitCode, err := state.Agent.Exec(execCtx, AgentExec{
309315 ID: fmt.Sprintf("%s-%d", wid.String(), idx),
310316 ExecStart: &agentv1.ExecStart{
311317 Argv: []string{guestShell, "-lc", step.Command()},
···318324 Stderr: stderr,
319325 })
320326 if err != nil {
321321- if ctx.Err() != nil {
322322- return engine.ErrTimedOut
323323- }
324324- return err
327327+ return e.classifyStepError(ctx, state, stderr, vmExited, err)
325328 }
326329327330 if exitCode != 0 {
328331 return engine.ErrWorkflowFailed
329332 }
330333 return nil
334334+}
335335+336336+// reads the vm serial logs so we report the tail of that as an error instead of
337337+// just "guest agent connection lost: EOF"
338338+func (e *Engine) classifyStepError(ctx context.Context, state *workflowState, stderr io.Writer, vmExited *atomic.Bool, err error) error {
339339+ if err == nil {
340340+ return nil
341341+ }
342342+ if vmExited != nil && vmExited.Load() {
343343+ if detail := vmCrashLog(state.VM); detail != "" {
344344+ fmt.Fprintf(stderr, "microVM exited unexpectedly:\n%s\n", detail)
345345+ e.l.Error("microVM exited unexpectedly during step", "detail", detail)
346346+ } else {
347347+ fmt.Fprintln(stderr, "microVM exited unexpectedly")
348348+ e.l.Error("microVM exited unexpectedly during step")
349349+ }
350350+ return errors.New("microVM exited unexpectedly; see workflow logs for serial/qemu output")
351351+ }
352352+ if ctx.Err() != nil {
353353+ return engine.ErrTimedOut
354354+ }
355355+ return err
331356}
332357333358func (e *Engine) activateConfig(ctx context.Context, wid models.WorkflowId, state *workflowState, step Step, out io.Writer) error {
+66-1
spindle/engines/microvm/vm.go
···66 "encoding/binary"
77 "errors"
88 "fmt"
99+ "io"
910 "log/slog"
1011 "math"
1112 "net"
···1314 "os/exec"
1415 "path/filepath"
1516 "strings"
1717+ "sync/atomic"
1618 "time"
17191820 "tangled.org/core/spindle/models"
1921)
20222121-const minGuestCID = 3
2323+const (
2424+ minGuestCID = 3
2525+ vmCrashLogTailBytes = 4096
2626+)
22272328func AllocateCID() (uint32, error) {
2429 var data [4]byte
···306311 err := os.RemoveAll(state.WorkDir)
307312 state.WorkDir = ""
308313 return err
314314+}
315315+316316+// returns a context derived from ctx that is cancelled either when ctx itself
317317+// is cancelled or when the microVM exits on its own. the returned flag reports
318318+// whether the VM exited (as opposed to ctx being cancelled for another reason,
319319+// e.g. the workflow timeout), letting callers tell a crash apart from a
320320+// timeout. cancel must be called to release the watcher goroutine.
321321+func watchVMExit(ctx context.Context, vm VMHandle) (context.Context, *atomic.Bool, context.CancelFunc) {
322322+ exited := &atomic.Bool{}
323323+ watchCtx, cancel := context.WithCancel(ctx)
324324+ if vm == nil {
325325+ return watchCtx, exited, cancel
326326+ }
327327+ go func() {
328328+ _ = vm.WaitContext(watchCtx) // returns when VM exits or watchCtx is cancelled
329329+ if watchCtx.Err() == nil {
330330+ exited.Store(true)
331331+ cancel() // don't forget to cancel the watchCtx...
332332+ }
333333+ }()
334334+ return watchCtx, exited, cancel
335335+}
336336+337337+func vmCrashLog(vm VMHandle) string {
338338+ if vm == nil {
339339+ return ""
340340+ }
341341+ logs := vm.Logs()
342342+343343+ var b strings.Builder
344344+ if tail := tailFile(logs.Serial, vmCrashLogTailBytes); tail != "" {
345345+ fmt.Fprintf(&b, "==== serial log ====\n%s\n", tail)
346346+ }
347347+ if qemuLog := logs.Extra["qemu"]; qemuLog != "" {
348348+ if tail := tailFile(qemuLog, vmCrashLogTailBytes); tail != "" {
349349+ fmt.Fprintf(&b, "==== qemu log ====\n%s\n", tail)
350350+ }
351351+ }
352352+ return strings.TrimRight(b.String(), "\n")
353353+}
354354+355355+func tailFile(path string, max int64) string {
356356+ if path == "" {
357357+ return ""
358358+ }
359359+ f, err := os.Open(path)
360360+ if err != nil {
361361+ return ""
362362+ }
363363+ defer f.Close()
364364+ if info, err := f.Stat(); err == nil && info.Size() > max {
365365+ if _, err := f.Seek(-max, io.SeekEnd); err != nil {
366366+ return ""
367367+ }
368368+ }
369369+ data, err := io.ReadAll(f)
370370+ if err != nil {
371371+ return ""
372372+ }
373373+ return strings.TrimSpace(string(data))
309374}
310375311376func waitAgentConn(ctx context.Context, connCh <-chan net.Conn) (net.Conn, error) {