From fb35b8e2e2db7938b4d39d14a05a60925405aaf7 Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Tue, 21 Apr 2026 11:01:01 -0700 Subject: [PATCH] fix(encoding): render for typed-nil Stringer pointers instead of panicking When an attribute value is a typed-nil pointer whose element type has a String method (the canonical case being (*time.Time)(nil), because time.Time.String auto-generates a pointer receiver wrapper), the interface assertion 'case fmt.Stringer' still matched, and calling v.String() dispatched the method on a nil receiver. The runtime then panicked with 'value method time.Time.String called using nil *Time pointer' and took down the handler - surfacing in real apps as a crash whenever an optional *time.Time field was unset (#22). Guard the Stringer branch with reflect: if the interface value holds a nil pointer, write the literal and return, matching the default Go format for a nil Stringer. For any non-nil Stringer, or a Stringer that does not resolve to a pointer (struct-by-value implementations, custom types), behaviour is unchanged. Add a regression test that passes a nil *time.Time as an attribute to the handler and asserts the output is 'INF foobar expiration=' rather than a panic. The test fails on master with the exact panic signature from the report. Closes #22 Signed-off-by: SAY-5 --- encoding.go | 14 ++++++++++++++ handler_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/encoding.go b/encoding.go index 6088e65..51b6ae7 100644 --- a/encoding.go +++ b/encoding.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "path/filepath" + "reflect" "runtime" "time" ) @@ -147,6 +148,19 @@ func (e encoder) writeValue(buf *buffer, value slog.Value) { e.writeColoredString(buf, v.Error(), e.opts.Theme.AttrValueError()) return case fmt.Stringer: + // A typed-nil pointer still satisfies the Stringer + // interface (e.g. (*time.Time)(nil) picks up the + // auto-generated pointer-receiver wrapper for + // time.Time.String), so calling v.String() would + // dispatch the method on a nil receiver and panic + // with 'value method ... called using nil * pointer' + // (#22). Render the literal instead so a + // missing optional field is just a missing attribute, + // not a crashed logger. + if rv := reflect.ValueOf(v); rv.Kind() == reflect.Pointer && rv.IsNil() { + e.writeColoredString(buf, "", attrValue) + return + } e.writeColoredString(buf, v.String(), attrValue) return } diff --git a/handler_test.go b/handler_test.go index eb09629..101c4c7 100644 --- a/handler_test.go +++ b/handler_test.go @@ -434,3 +434,27 @@ func TestThemes(t *testing.T) { }) } } + +// TestHandler_NilStringerPointer is a regression for #22. Logging a +// typed-nil pointer whose underlying value type implements fmt.Stringer +// (e.g. (*time.Time)(nil)) used to dispatch the String method on the +// nil receiver and panic with 'value method time.Time.String called +// using nil *Time pointer'. The handler must render "" instead. +func TestHandler_NilStringerPointer(t *testing.T) { + buf := bytes.Buffer{} + h := NewHandler(&buf, &HandlerOptions{NoColor: true}) + rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "foobar", 0) + + var expiration *time.Time + rec.AddAttrs(slog.Any("expiration", expiration)) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("handler panicked on nil *time.Time: %v", r) + } + }() + AssertNoError(t, h.Handle(context.Background(), rec)) + + expected := "INF foobar expiration=\n" + AssertEqual(t, expected, buf.String()) +}