This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git


The following commit(s) were added to refs/heads/develop by this push:
     new 220b7760cb feat(plc4go): surface corrupt error chains instead of 
silently swallowing them
220b7760cb is described below

commit 220b7760cb8723beeefb89ae80900ab1d96af955
Author: Sebastian Rühl <[email protected]>
AuthorDate: Thu Jul 2 13:03:02 2026 +0200

    feat(plc4go): surface corrupt error chains instead of silently swallowing 
them
    
    The panic guards added for improperly constructed error chains (typed-nil
    *net.OpError wrapped via %w) recover silently, which converts a loud
    worker-killing signal into an invisible one - future occurrences of the
    underlying anomaly would go unnoticed. Restore the signal without
    introducing any logging at the utility level:
    
    - errors.SanitizeError: returns the error unchanged when its chain walks
      safely (including multi-child Join nodes, Error() panics, and cyclic
      chains). A corrupt chain is flattened to its message and joined with the
      new errors.ErrCorruptErrorChain sentinel - safe to walk, keeps the
      original text, and is detectable via errors.Is wherever the error is
      eventually logged or propagated. Joining the ORIGINAL error would re-arm
      the poisoned chain one level deeper (Join's Unwrap hands it back to every
      walk), hence the flatten.
    
    - defaultCodec.handleTransportError sanitizes once at the entry point, so
      classification, TransportError wrapping, expectation fan-out, and client
      propagation all operate on a safe chain that still carries the anomaly.
    
    - errors.MarshalStack now reports a recovered panic as the marshaled
      payload (under the "stack" key of the log event that was already logging
      the error) instead of returning nil - scoped to the exact log line, no
      global logger involved.
    
    Tests cover: clean-chain identity, typed-nil in chain and at top level,
    panicking Error(), cyclic chains, Join with poisoned child, sentinel
    survival through TransportError wrapping to the transport-error handler,
    and the MarshalStack panic marker.
---
 plc4go/spi/default/DefaultCodec.go                 |  12 +-
 .../DefaultCodecTransportErrorPanic_test.go        |  34 ++++++
 plc4go/spi/errors/marshal.go                       |  13 ++-
 plc4go/spi/errors/sanitize.go                      | 104 ++++++++++++++++++
 plc4go/spi/errors/sanitize_test.go                 | 122 +++++++++++++++++++++
 5 files changed, 280 insertions(+), 5 deletions(-)

diff --git a/plc4go/spi/default/DefaultCodec.go 
b/plc4go/spi/default/DefaultCodec.go
index b328265627..e60f92a97d 100644
--- a/plc4go/spi/default/DefaultCodec.go
+++ b/plc4go/spi/default/DefaultCodec.go
@@ -543,10 +543,14 @@ func (m *defaultCodec) handleTransportError(workerLog 
zerolog.Logger, err error)
        if err == nil {
                return true
        }
-       // Use the panic-guarded transports.ErrorIs here: the error chain 
delivered by a
-       // transport can contain improperly constructed values (e.g. a typed-nil
-       // *net.OpError), and stdErrors.Is dereferences them while unwrapping - 
which
-       // killed the receive worker with a recovered nil-pointer panic in the 
field.
+       // Defuse improperly constructed error chains (e.g. a typed-nil 
*net.OpError
+       // wrapped via %w) once at the entry point: everything below - 
classification,
+       // wrapping, expectation fan-out, logging - walks the chain repeatedly, 
and
+       // unguarded walks dereference such values, which killed the receive 
worker
+       // with a recovered nil-pointer panic in the field. A corrupt chain is
+       // flattened and tagged with errors.ErrCorruptErrorChain so the anomaly
+       // stays visible downstream instead of being silently swallowed.
+       err = errors.SanitizeError(err)
        if transports.ErrorIs(err, context.Canceled) {
                workerLog.Debug().Msg("receive aborted due to context 
cancellation")
                return false
diff --git a/plc4go/spi/default/DefaultCodecTransportErrorPanic_test.go 
b/plc4go/spi/default/DefaultCodecTransportErrorPanic_test.go
index 161a764b07..10d9331e60 100644
--- a/plc4go/spi/default/DefaultCodecTransportErrorPanic_test.go
+++ b/plc4go/spi/default/DefaultCodecTransportErrorPanic_test.go
@@ -20,13 +20,18 @@
 package _default
 
 import (
+       stderrors "errors"
        "fmt"
        "net"
        "testing"
+       "time"
 
        "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
 
+       spiErrors "github.com/apache/plc4x/plc4go/spi/errors"
        "github.com/apache/plc4x/plc4go/spi/testutils"
+       "github.com/apache/plc4x/plc4go/spi/transports"
 )
 
 // A transport error chain can contain improperly constructed values - most
@@ -45,3 +50,32 @@ func 
Test_defaultCodec_handleTransportError_typedNilOpErrorInChain(t *testing.T)
                assert.False(t, keepRunning, "poisoned chain must be treated as 
fatal and stop the worker")
        })
 }
+
+// The sanitized error handed to the transport-error handler (and thus to the
+// owning connection / client code) must be safe to walk, carry the
+// ErrCorruptErrorChain sentinel, and preserve the original message text.
+func Test_defaultCodec_handleTransportError_sanitizedErrorReachesHandler(t 
*testing.T) {
+       var nilOpErr *net.OpError
+       poisoned := fmt.Errorf("read failed: %w", error(nilOpErr))
+
+       received := make(chan error, 1)
+       codec := &defaultCodec{
+               transportErrorHandler: func(kind transports.TransportErrorKind, 
err error) {
+                       assert.Equal(t, transports.TransportErrorFatal, kind)
+                       received <- err
+               },
+       }
+       assert.NotPanics(t, func() {
+               codec.handleTransportError(testutils.ProduceTestingLogger(t), 
poisoned)
+       })
+
+       select {
+       case err := <-received:
+               require.NotNil(t, err)
+               assert.NotPanics(t, func() { _ = stderrors.Is(err, 
stderrors.New("x")) }, "handler error must be safe to walk")
+               assert.True(t, stderrors.Is(err, 
spiErrors.ErrCorruptErrorChain), "sentinel must survive TransportError 
wrapping")
+               assert.Contains(t, err.Error(), "read failed", "original 
message must be preserved")
+       case <-time.After(5 * time.Second):
+               t.Fatal("transport error handler was never invoked")
+       }
+}
diff --git a/plc4go/spi/errors/marshal.go b/plc4go/spi/errors/marshal.go
index f5093f4254..f375cd2c74 100644
--- a/plc4go/spi/errors/marshal.go
+++ b/plc4go/spi/errors/marshal.go
@@ -19,6 +19,8 @@
 
 package errors
 
+import "fmt"
+
 // Constants compatible with github.com/rs/zerolog/pkgerrors so callers can do
 // entry[errors.StackSourceFileName] and keep working when swapped over.
 const (
@@ -27,6 +29,10 @@ const (
        StackSourceFunctionName = "func"
 )
 
+// StackMarshalPanicKey is the key under which MarshalStack reports a panic it
+// recovered while walking the error chain (instead of stack frames).
+const StackMarshalPanicKey = "error"
+
 // MarshalStack walks err's chain looking for a value that implements
 //
 //     interface{ StackTrace() StackTrace }
@@ -39,10 +45,15 @@ const (
 // values (e.g. a typed-nil *net.OpError wrapped via %w) whose Unwrap method
 // dereferences a nil receiver. This marshaler runs inside zerolog on every
 // logged error, so a panic here would take down whatever worker is logging.
+// Instead of swallowing the anomaly, the recovered panic is reported as the
+// marshaled payload - it lands under the "stack" key of the very log event
+// that was logging the error, so no logger is needed at this level.
 func MarshalStack(err error) (result any) {
        defer func() {
                if r := recover(); r != nil {
-                       result = nil
+                       result = []map[string]string{{
+                               StackMarshalPanicKey: fmt.Sprintf("panic while 
extracting stack from error chain: %v", r),
+                       }}
                }
        }()
        type stackTracer interface {
diff --git a/plc4go/spi/errors/sanitize.go b/plc4go/spi/errors/sanitize.go
new file mode 100644
index 0000000000..3d08bf64ff
--- /dev/null
+++ b/plc4go/spi/errors/sanitize.go
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package errors
+
+import (
+       stderrors "errors"
+       "fmt"
+)
+
+// ErrCorruptErrorChain marks an error whose original chain could not be walked
+// safely. Detect it with errors.Is(err, ErrCorruptErrorChain) after the error
+// passed through SanitizeError.
+var ErrCorruptErrorChain = stderrors.New("corrupt error chain (panic during 
unwrap)")
+
+// sanitizeMaxDepth bounds the chain walk so a (broken) cyclic chain classifies
+// as corrupt instead of hanging the caller.
+const sanitizeMaxDepth = 256
+
+// SanitizeError returns err unchanged when its chain can be walked safely.
+//
+// If walking the chain panics - which happens when improperly constructed
+// values (e.g. a typed-nil *net.OpError wrapped via %w) are part of the chain,
+// as their Unwrap/Error methods dereference a nil receiver - it returns a
+// flattened copy of the error message joined with ErrCorruptErrorChain. The
+// result is safe for errors.Is/As/Unwrap, keeps the original message in the
+// error string, and carries the anomaly as an Is-detectable sentinel, so it
+// stays visible wherever the caller eventually logs or propagates the error.
+// No logging happens at this level.
+//
+// Note: joining the ORIGINAL error with the sentinel would not defuse
+// anything - Join's Unwrap() []error hands the poisoned chain right back to
+// every future walk. That is why the message is flattened instead.
+func SanitizeError(err error) error {
+       if err == nil {
+               return nil
+       }
+       if chainWalksSafely(err) {
+               return err
+       }
+       return stderrors.Join(stderrors.New(safeErrorString(err)), 
ErrCorruptErrorChain)
+}
+
+// chainWalksSafely performs the same traversal errors.Is would (including
+// multi-child Join nodes) and reports whether it completes without panicking.
+func chainWalksSafely(err error) (safe bool) {
+       defer func() {
+               if r := recover(); r != nil {
+                       safe = false
+               }
+       }()
+       return walkChain(err, 0)
+}
+
+func walkChain(err error, depth int) bool {
+       for err != nil {
+               if depth > sanitizeMaxDepth {
+                       return false
+               }
+               depth++
+               _ = err.Error() // Error() itself may dereference a nil receiver
+               switch x := err.(type) {
+               case interface{ Unwrap() error }:
+                       err = x.Unwrap()
+               case interface{ Unwrap() []error }:
+                       for _, child := range x.Unwrap() {
+                               if !walkChain(child, depth) {
+                                       return false
+                               }
+                       }
+                       return true
+               default:
+                       return true
+               }
+       }
+       return true
+}
+
+// safeErrorString renders err.Error() with a panic guard, falling back to a
+// placeholder when even the message cannot be produced.
+func safeErrorString(err error) (s string) {
+       defer func() {
+               if r := recover(); r != nil {
+                       s = fmt.Sprintf("(error message unavailable: panic in 
Error(): %v)", r)
+               }
+       }()
+       return err.Error()
+}
diff --git a/plc4go/spi/errors/sanitize_test.go 
b/plc4go/spi/errors/sanitize_test.go
new file mode 100644
index 0000000000..4d670ab7c1
--- /dev/null
+++ b/plc4go/spi/errors/sanitize_test.go
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package errors
+
+import (
+       stderrors "errors"
+       "fmt"
+       "net"
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// panickyError panics in Error() - simulating a node whose message cannot even
+// be rendered.
+type panickyError struct{}
+
+func (p *panickyError) Error() string { panic("boom in Error()") }
+
+// selfWrapping unwraps to itself - a (broken) cyclic chain.
+type selfWrapping struct{}
+
+func (s *selfWrapping) Error() string { return "cyclic" }
+func (s *selfWrapping) Unwrap() error { return s }
+
+func poisonedChain() error {
+       var nilOpErr *net.OpError
+       return fmt.Errorf("read failed: %w", error(nilOpErr))
+}
+
+func TestSanitizeError_nil(t *testing.T) {
+       assert.Nil(t, SanitizeError(nil))
+}
+
+func TestSanitizeError_cleanChainReturnedUnchanged(t *testing.T) {
+       inner := stderrors.New("inner")
+       err := fmt.Errorf("outer: %w", inner)
+       sanitized := SanitizeError(err)
+       assert.Equal(t, err, sanitized, "clean chains must pass through 
untouched")
+       assert.True(t, stderrors.Is(sanitized, inner), "chain semantics must be 
preserved")
+}
+
+func TestSanitizeError_typedNilInChain(t *testing.T) {
+       sanitized := SanitizeError(poisonedChain())
+       require.NotNil(t, sanitized)
+
+       // The sanitized error must be safe to walk...
+       assert.NotPanics(t, func() {
+               _ = stderrors.Is(sanitized, ErrCorruptErrorChain)
+               var target *net.OpError
+               _ = stderrors.As(sanitized, &target)
+       })
+       // ...carry the sentinel...
+       assert.True(t, stderrors.Is(sanitized, ErrCorruptErrorChain))
+       // ...and preserve the original message text.
+       assert.Contains(t, sanitized.Error(), "read failed")
+}
+
+func TestSanitizeError_typedNilAtTopLevel(t *testing.T) {
+       var nilOpErr *net.OpError
+       sanitized := SanitizeError(error(nilOpErr))
+       require.NotNil(t, sanitized)
+       assert.True(t, stderrors.Is(sanitized, ErrCorruptErrorChain))
+       assert.NotPanics(t, func() { _ = stderrors.Is(sanitized, 
stderrors.New("x")) })
+}
+
+func TestSanitizeError_panickyErrorMessage(t *testing.T) {
+       err := fmt.Errorf("outer: %w", error(&panickyError{}))
+       // Chain walk panics via Error() on the child -> corrupt. Note 
fmt.Errorf
+       // pre-renders the message, so the flattened string comes from the 
wrapper
+       // and stays usable; a top-level panicky Error() falls back to a 
placeholder.
+       sanitized := SanitizeError(err)
+       assert.True(t, stderrors.Is(sanitized, ErrCorruptErrorChain))
+
+       topLevel := SanitizeError(error(&panickyError{}))
+       assert.True(t, stderrors.Is(topLevel, ErrCorruptErrorChain))
+       assert.Contains(t, topLevel.Error(), "error message unavailable")
+}
+
+func TestSanitizeError_cyclicChain(t *testing.T) {
+       sanitized := SanitizeError(error(&selfWrapping{}))
+       assert.True(t, stderrors.Is(sanitized, ErrCorruptErrorChain), "cyclic 
chains must classify as corrupt instead of hanging")
+       assert.Contains(t, sanitized.Error(), "cyclic")
+}
+
+func TestSanitizeError_joinWithPoisonedChild(t *testing.T) {
+       joined := stderrors.Join(stderrors.New("healthy"), poisonedChain())
+       sanitized := SanitizeError(joined)
+       assert.True(t, stderrors.Is(sanitized, ErrCorruptErrorChain))
+       assert.Contains(t, sanitized.Error(), "healthy")
+       assert.NotPanics(t, func() { _ = stderrors.Is(sanitized, 
ErrCorruptErrorChain) })
+}
+
+func TestMarshalStack_poisonedChainReportsPanicMarker(t *testing.T) {
+       var result any
+       assert.NotPanics(t, func() {
+               result = MarshalStack(poisonedChain())
+       })
+       require.NotNil(t, result, "the recovered panic must be reported, not 
swallowed")
+       marker, ok := result.([]map[string]string)
+       require.True(t, ok)
+       require.Len(t, marker, 1)
+       assert.Contains(t, marker[0][StackMarshalPanicKey], "panic while 
extracting stack")
+}

Reply via email to