From 91a1c3d4d4501e19c6ebba92917fd352a78f0b21 Mon Sep 17 00:00:00 2001 From: Eric Li Date: Mon, 2 Nov 2026 18:03:09 -0500 Subject: [PATCH] fix(core): exit non-zero when nylon stops on a fatal error Nylon previously returned 0 for all errors after startup, this fixes it so that fatal non SIGINT/SIGTERM errors return a nonzero error code --- core/nylon.go | 11 +++++++++-- integration/harness.go | 9 ++++++++- integration/ipc_test.go | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/core/nylon.go b/core/nylon.go index e78d7bb9..7ac1ca1f 100644 --- a/core/nylon.go +++ b/core/nylon.go @@ -329,6 +329,8 @@ func (n *Nylon) Init() error { return nil } +var errShutdownSignal = errors.New("received shutdown signal") + func (n *Nylon) Start() error { n.Log.Info("init modules complete") @@ -343,7 +345,7 @@ func (n *Nylon) Start() error { go func() { select { case _ = <-c: - n.Cancel(errors.New("received shutdown signal")) + n.Cancel(errShutdownSignal) case <-n.Context.Done(): return } @@ -402,7 +404,12 @@ endLoop: n.Log.Error("error occurred during Stop: ", "error", err) } n.Log.Info("stopped") - return nil + // report fatal errors so the process exits non-zero + cause := context.Cause(n.Context) + if errors.Is(cause, context.Canceled) || errors.Is(cause, errShutdownSignal) { + return nil + } + return cause } func (n *Nylon) Cleanup() error { diff --git a/integration/harness.go b/integration/harness.go index 5a5d1ddf..5e20f5c8 100644 --- a/integration/harness.go +++ b/integration/harness.go @@ -75,6 +75,9 @@ type Harness struct { // UntrackedRouting turns off UseSystemRouting on every node, so packets // for peers stay inside polyamide instead of coming out of the TUN. UntrackedRouting bool + // OnStartError, if set, receives errors returned by a node's Start + // instead of failing the test. + OnStartError func(id state.NodeId, err error) ctx context.Context cancel context.CancelCauseFunc @@ -238,7 +241,11 @@ func (h *Harness) launch(nd *node, n *core.Nylon, delay time.Duration) { } pprof.Do(context.Background(), pprof.Labels("nylon node", string(n.LocalCfg.Id)), func(context.Context) { if err := n.Start(); err != nil { - h.fail("node %s: %v", n.LocalCfg.Id, err) + if h.OnStartError != nil { + h.OnStartError(n.LocalCfg.Id, err) + } else { + h.fail("node %s: %v", n.LocalCfg.Id, err) + } } }) }() diff --git a/integration/ipc_test.go b/integration/ipc_test.go index 170f735c..c472c7ec 100644 --- a/integration/ipc_test.go +++ b/integration/ipc_test.go @@ -279,3 +279,23 @@ func TestIPCConcurrentRequestsAndCancel(t *testing.T) { require.Less(t, time.Since(start), 4*time.Second, "the cancelled probe should end before its timeout") }) } + +// A node stopped by a fatal error must report it so nylon run exits non-zero. +func TestStartReturnsFatalDispatchError(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + h := NewHarness(t) + errs := make(chan error, 2) + h.OnStartError = func(_ state.NodeId, err error) { errs <- err } + a1, b1 := "192.168.54.1:1234", "192.168.54.2:1234" + h.AddNode("a", "10.0.0.1/32", a1) + h.AddNode("b", "10.0.0.2/32", b1) + h.Central.Graph = []string{"a, b"} + h.Connect(a1, b1) + h.Start() + time.Sleep(time.Second) // let both nodes start + + h.Nylon("a").Dispatch(func() error { return errors.New("fatal") }) + err := await(h, errs, 5*time.Second, "a's Start to return") + require.ErrorContains(t, err, "fatal") + }) +}