diff --git a/cmd/mesh-host/apply_context_test.go b/cmd/mesh-host/apply_context_test.go new file mode 100644 index 0000000..6b7c820 --- /dev/null +++ b/cmd/mesh-host/apply_context_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "testing" +) + +// An apply runs in a context the bus's loss cannot end: the link's context ends when the bus goes, and the bus may go +// because of the apply itself (it replaces the bus's own container), which then must finish (issue 1945, live on +// 2026-10-25 at 11:23: novox's node-engine removed the bus container, its link's context ended, and the docker run that +// would have made the new one was never made). +func TestAnApplyOutlivesTheLinksContext(t *testing.T) { + type key struct{} + link, cancel := context.WithCancel(context.WithValue(context.Background(), key{}, "v")) + ctx := applyContext(link) + cancel() + if ctx.Err() != nil { + t.Fatalf("the apply's context ended with the link's: %v", ctx.Err()) + } + if ctx.Value(key{}) != "v" { + t.Fatal("the apply's context lost the link's values") + } +} diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 7dd18f9..80f0bc3 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -298,13 +298,13 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess Backup: backupNode(session), Say: say, Apply: func(ctx context.Context, c delivery.Composed, others []delivery.Composed) agent.Result { - res := applyComposition(session.WithLink(ctx), opts, c, others, sched, say) + res := applyComposition(applyContext(session.WithLink(ctx)), opts, c, others, sched, say) afterApply() sayFacts(ctx, ag) return res }, Remove: func(ctx context.Context, module string, others []delivery.Composed) agent.Result { - res := removeComposition(session.WithLink(ctx), opts, module, others, sched, say) + res := removeComposition(applyContext(session.WithLink(ctx)), opts, module, others, sched, say) afterApply() sayFacts(ctx, ag) return res @@ -567,3 +567,9 @@ func declaresNothingToBackUp(said json.RawMessage, module string) (nothing bool, } return true, true } + +// applyContext is the context an apply or a removal runs in: the link's values, never its end. The link's context ends +// when the bus goes, and the bus may go because of the apply itself (it replaces the bus's own container), which must +// then finish: a container removed and never made again took the mesh down (issue 1945, live on 2026-10-25 at 11:23). +// An apply is bounded by its own steps' bounds, not by the link. +func applyContext(ctx context.Context) context.Context { return context.WithoutCancel(ctx) } diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 731d432..9e72a9f 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -2464,12 +2464,18 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error if err != nil { var exit *exec.ExitError if errors.As(err, &exit) { - // The words as they always were, and the exit underneath them, so a caller asks the - // code (system.ExitCode) rather than matching text that this line is free to reword. - return string(out), &exited{ - words: fmt.Sprintf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr))), - exit: exit, + words := fmt.Sprintf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr))) + if exit.ExitCode() == -1 { + // Killed, not exited: say by what, and the context's end when that is what killed it (issue 1945: + // "docker exited -1:" said nothing while a bus container was half replaced). + words = fmt.Sprintf("%s was %s", name, exit.String()) + if ctx.Err() != nil { + words += " because its context ended: " + ctx.Err().Error() + } } + // The words, and the exit underneath them, so a caller asks the code (system.ExitCode) rather than + // matching text that this line is free to reword. + return string(out), &exited{words: words, exit: exit} } return string(out), fmt.Errorf("%s: %w", name, err) } diff --git a/internal/apply/exec_killed_test.go b/internal/apply/exec_killed_test.go new file mode 100644 index 0000000..24685f6 --- /dev/null +++ b/internal/apply/exec_killed_test.go @@ -0,0 +1,19 @@ +package apply + +import ( + "context" + "strings" + "testing" + "time" +) + +// A command killed because its context ended says so, and why: "docker exited -1:" said nothing while novox's bus +// container was half replaced (issue 1945, live on 2026-10-25 at 11:23). +func TestACommandKilledByItsContextSaysWhy(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { time.Sleep(50 * time.Millisecond); cancel() }() + _, err := ExecRunner(ctx, "sleep", "5") + if err == nil || !strings.Contains(err.Error(), "killed") || !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("a command its context killed said: %v", err) + } +}