Skip to content

Commit bf7870a

Browse files
authored
feat: lazy stack trace capture with deferred symbolization (#19)
* feat: lazy stack trace capture with deferred symbolization Replace eager per-frame runtime.Caller + runtime.FuncForPC with batch runtime.Callers for PC capture, deferring symbolization to first StackFrame() access via sync.Once. Performance (errors.New): 3500ns→540ns (6.5x), 1600B→392B (75% less) Deep stacks (32 frames): 40500ns→1060ns (38x), 17KB→392B (98% less) Changes: - Default stack depth reduced from 64 to 16 (configurable via SetMaxStackDepth) - captureStack: single runtime.Callers call instead of loop of runtime.Caller - StackFrame: lazy resolution via sync.Once + runtime.CallersFrames - All receivers changed to pointer (required by sync.Once, consistent) - SetMaxStackDepth: atomic.Int32 for concurrent safety - resolveFrames: pre-allocated slice, strings.TrimPrefix for base path - splitFuncName: takes string instead of uintptr (no runtime.FuncForPC) * fix: clamp SetMaxStackDepth to 1-256 range * fix: snapshot basePath at capture time, add tests, document depth limit - Snapshot basePath in captureStack so lazy resolution uses the value from error creation time, not access time (eliminates race) - resolveFrames takes basePath parameter instead of reading global - Document SetMaxStackDepth accepts [1, 256] - Add TestSetMaxStackDepth (valid, zero, negative, over-256) - Add TestStackFrameConsistency (repeated calls, Callers parity) - Update existing depth tests for new default of 16 * fix: copy basePath on wrap, cap resolveFrames for inlining, relax test assertions - Copy basePath from wrapped ErrorExt so path trimming works on re-wrapped errors - Cap resolveFrames output at len(pcs) to handle inlining frame expansion - Test assertions use Callers() length (PCs) instead of StackFrame() length - Relax 1:1 PC-to-frame assumption in TestStackFrameConsistency * fix: use atomic.Value for basePath to eliminate read/write race
1 parent c1f9b08 commit bf7870a

5 files changed

Lines changed: 213 additions & 71 deletions

File tree

README.md

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ true
107107

108108
## Index
109109

110+
- [Constants](<#constants>)
110111
- [func SetBaseFilePath\(path string\)](<#SetBaseFilePath>)
111112
- [func SetMaxStackDepth\(n int\)](<#SetMaxStackDepth>)
112113
- [type ErrorExt](<#ErrorExt>)
@@ -124,8 +125,16 @@ true
124125
- [type StackFrame](<#StackFrame>)
125126

126127

128+
## Constants
129+
130+
<a name="SupportPackageIsVersion1"></a>SupportPackageIsVersion1 is a compile\-time assertion constant. Downstream packages reference this to enforce version compatibility.
131+
132+
```go
133+
const SupportPackageIsVersion1 = true
134+
```
135+
127136
<a name="SetBaseFilePath"></a>
128-
## func [SetBaseFilePath](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L257>)
137+
## func [SetBaseFilePath](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L281>)
129138

130139
```go
131140
func SetBaseFilePath(path string)
@@ -134,16 +143,16 @@ func SetBaseFilePath(path string)
134143
SetBaseFilePath sets the base file path for linking source code with reported stack information
135144

136145
<a name="SetMaxStackDepth"></a>
137-
## func [SetMaxStackDepth](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L240>)
146+
## func [SetMaxStackDepth](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L264>)
138147

139148
```go
140149
func SetMaxStackDepth(n int)
141150
```
142151

143-
SetMaxStackDepth sets the maximum number of stack frames captured when creating errors. Default is 64. Must be called during initialization.
152+
SetMaxStackDepth sets the maximum number of stack frames captured when creating errors. Accepts values in \[1, 256\]; out\-of\-range values are ignored. Default is 16. Safe for concurrent use.
144153

145154
<a name="ErrorExt"></a>
146-
## type [ErrorExt](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L26-L37>)
155+
## type [ErrorExt](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L34-L45>)
147156

148157
ErrorExt is the interface that defines a error, any ErrorExt implementors can use and override errors and notifier package
149158

@@ -163,7 +172,7 @@ type ErrorExt interface {
163172
```
164173

165174
<a name="New"></a>
166-
### func [New](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L156>)
175+
### func [New](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L180>)
167176

168177
```go
169178
func New(msg string) ErrorExt
@@ -201,7 +210,7 @@ something went wrong
201210
</details>
202211

203212
<a name="NewWithSkip"></a>
204-
### func [NewWithSkip](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L166>)
213+
### func [NewWithSkip](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L190>)
205214

206215
```go
207216
func NewWithSkip(msg string, skip int) ErrorExt
@@ -210,7 +219,7 @@ func NewWithSkip(msg string, skip int) ErrorExt
210219
NewWithSkip creates a new error skipping the number of function on the stack
211220

212221
<a name="NewWithSkipAndStatus"></a>
213-
### func [NewWithSkipAndStatus](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L171>)
222+
### func [NewWithSkipAndStatus](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L195>)
214223

215224
```go
216225
func NewWithSkipAndStatus(msg string, skip int, status *grpcstatus.Status) ErrorExt
@@ -219,7 +228,7 @@ func NewWithSkipAndStatus(msg string, skip int, status *grpcstatus.Status) Error
219228
NewWithSkipAndStatus creates a new error skipping the number of function on the stack and GRPC status
220229

221230
<a name="NewWithStatus"></a>
222-
### func [NewWithStatus](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L161>)
231+
### func [NewWithStatus](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L185>)
223232

224233
```go
225234
func NewWithStatus(msg string, status *grpcstatus.Status) ErrorExt
@@ -228,7 +237,7 @@ func NewWithStatus(msg string, status *grpcstatus.Status) ErrorExt
228237
NewWithStatus creates a new error with statck information and GRPC status
229238

230239
<a name="Newf"></a>
231-
### func [Newf](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L247>)
240+
### func [Newf](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L271>)
232241

233242
```go
234243
func Newf(format string, args ...any) ErrorExt
@@ -266,7 +275,7 @@ user alice not found
266275
</details>
267276

268277
<a name="Wrap"></a>
269-
### func [Wrap](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L176>)
278+
### func [Wrap](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L200>)
270279

271280
```go
272281
func Wrap(err error, msg string) ErrorExt
@@ -340,7 +349,7 @@ true
340349
</details>
341350

342351
<a name="WrapWithSkip"></a>
343-
### func [WrapWithSkip](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L186>)
352+
### func [WrapWithSkip](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L210>)
344353

345354
```go
346355
func WrapWithSkip(err error, msg string, skip int) ErrorExt
@@ -349,7 +358,7 @@ func WrapWithSkip(err error, msg string, skip int) ErrorExt
349358
WrapWithSkip wraps an existing error and appends stack information if it does not exists skipping the number of function on the stack
350359

351360
<a name="WrapWithSkipAndStatus"></a>
352-
### func [WrapWithSkipAndStatus](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L191>)
361+
### func [WrapWithSkipAndStatus](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L215>)
353362

354363
```go
355364
func WrapWithSkipAndStatus(err error, msg string, skip int, status *grpcstatus.Status) ErrorExt
@@ -358,7 +367,7 @@ func WrapWithSkipAndStatus(err error, msg string, skip int, status *grpcstatus.S
358367
WrapWithSkip wraps an existing error and appends stack information if it does not exists skipping the number of function on the stack along with GRPC status
359368

360369
<a name="WrapWithStatus"></a>
361-
### func [WrapWithStatus](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L181>)
370+
### func [WrapWithStatus](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L205>)
362371

363372
```go
364373
func WrapWithStatus(err error, msg string, status *grpcstatus.Status) ErrorExt
@@ -403,7 +412,7 @@ gRPC code: NotFound
403412
</details>
404413

405414
<a name="Wrapf"></a>
406-
### func [Wrapf](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L252>)
415+
### func [Wrapf](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L276>)
407416

408417
```go
409418
func Wrapf(err error, format string, args ...any) ErrorExt
@@ -442,7 +451,7 @@ failed to connect to port 5432: connection refused
442451
</details>
443452

444453
<a name="NotifyExt"></a>
445-
## type [NotifyExt](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L40-L45>)
454+
## type [NotifyExt](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L48-L53>)
446455

447456
NotifyExt is the interface definition for notifier related options
448457

@@ -456,7 +465,7 @@ type NotifyExt interface {
456465
```
457466

458467
<a name="StackFrame"></a>
459-
## type [StackFrame](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L19-L23>)
468+
## type [StackFrame](<https://github.com/go-coldbrew/errors/blob/main/errors.go#L27-L31>)
460469

461470
StackFrame represents the stackframe for tracing exception
462471

bench_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package errors
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func BenchmarkNew(b *testing.B) {
8+
b.ReportAllocs()
9+
for b.Loop() {
10+
_ = New("benchmark error")
11+
}
12+
}
13+
14+
func BenchmarkNewAndStackFrame(b *testing.B) {
15+
b.ReportAllocs()
16+
for b.Loop() {
17+
e := New("benchmark error")
18+
_ = e.StackFrame()
19+
}
20+
}
21+
22+
func BenchmarkWrap(b *testing.B) {
23+
base := New("base error")
24+
b.ReportAllocs()
25+
b.ResetTimer()
26+
for b.Loop() {
27+
_ = Wrap(base, "wrapped")
28+
}
29+
}
30+
31+
func BenchmarkNewDeepStack(b *testing.B) {
32+
b.ReportAllocs()
33+
var recurse func(depth int) ErrorExt
34+
recurse = func(depth int) ErrorExt {
35+
if depth == 0 {
36+
return New("deep error")
37+
}
38+
return recurse(depth - 1)
39+
}
40+
for b.Loop() {
41+
_ = recurse(32)
42+
}
43+
}

errors.go

Lines changed: 61 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"fmt"
66
"runtime"
77
"strings"
8+
"sync"
9+
"sync/atomic"
810

911
"google.golang.org/grpc/codes"
1012
grpcstatus "google.golang.org/grpc/status"
@@ -14,9 +16,11 @@ import (
1416
// Downstream packages reference this to enforce version compatibility.
1517
const SupportPackageIsVersion1 = true
1618

19+
const defaultStackDepth = 16
20+
1721
var (
18-
basePath = ""
19-
maxStackDepth = 64
22+
atomicBasePath atomic.Value // stores string
23+
atomicStackDepth atomic.Int32
2024
)
2125

2226
// StackFrame represents the stackframe for tracing exception
@@ -52,6 +56,8 @@ type customError struct {
5256
Msg string
5357
stack []uintptr
5458
frame []StackFrame
59+
frameOnce sync.Once
60+
basePath string // snapshot of basePath at capture time
5561
cause error
5662
wrapped error // immediate parent for Unwrap() chain; may differ from cause
5763
shouldNotify bool
@@ -69,32 +75,38 @@ func (c *customError) Notified(status bool) {
6975
}
7076

7177
// Error returns the error message.
72-
func (c customError) Error() string {
78+
func (c *customError) Error() string {
7379
return c.Msg
7480
}
7581

7682
// Callers returns the program counters of the call stack when the error was created.
77-
func (c customError) Callers() []uintptr {
83+
func (c *customError) Callers() []uintptr {
7884
return c.stack[:]
7985
}
8086

8187
// StackTrace returns the program counters of the call stack (alias for Callers).
82-
func (c customError) StackTrace() []uintptr {
88+
func (c *customError) StackTrace() []uintptr {
8389
return c.Callers()
8490
}
8591

8692
// StackFrame returns the structured stack frames for the error.
87-
func (c customError) StackFrame() []StackFrame {
93+
// Frames are resolved lazily from program counters on first access.
94+
func (c *customError) StackFrame() []StackFrame {
95+
c.frameOnce.Do(func() {
96+
if len(c.stack) > 0 {
97+
c.frame = resolveFrames(c.stack, c.basePath)
98+
}
99+
})
88100
return c.frame
89101
}
90102

91103
// Cause returns the root cause error that originated this error chain.
92-
func (c customError) Cause() error {
104+
func (c *customError) Cause() error {
93105
return c.cause
94106
}
95107

96108
// GRPCStatus returns the gRPC status for this error.
97-
func (c customError) GRPCStatus() *grpcstatus.Status {
109+
func (c *customError) GRPCStatus() *grpcstatus.Status {
98110
if c.status != nil {
99111
// use latest error message and keep other data (e.g. details)
100112
newStatus := c.status.Proto()
@@ -106,43 +118,52 @@ func (c customError) GRPCStatus() *grpcstatus.Status {
106118
return grpcstatus.New(codes.Internal, c.Error())
107119
}
108120

109-
func (c *customError) generateStack(skip int) []StackFrame {
110-
stack := []StackFrame{}
111-
trace := []uintptr{}
112-
for i := skip + 1; i < skip+1+maxStackDepth; i++ {
113-
pc, file, line, ok := runtime.Caller(i)
114-
if !ok {
115-
break
116-
}
117-
_, funcName := packageFuncName(pc)
118-
if basePath != "" {
119-
file = strings.Replace(file, basePath, "", 1)
121+
// captureStack records program counters for the call stack.
122+
// Symbolization is deferred until StackFrame() is called.
123+
func (c *customError) captureStack(skip int) {
124+
depth := int(atomicStackDepth.Load())
125+
if depth == 0 {
126+
depth = defaultStackDepth
127+
}
128+
pcs := make([]uintptr, depth)
129+
n := runtime.Callers(skip+2, pcs)
130+
c.stack = pcs[:n]
131+
c.basePath, _ = atomicBasePath.Load().(string)
132+
}
133+
134+
// resolveFrames converts program counters to structured stack frames.
135+
func resolveFrames(pcs []uintptr, base string) []StackFrame {
136+
frames := runtime.CallersFrames(pcs)
137+
maxFrames := len(pcs)
138+
stack := make([]StackFrame, 0, maxFrames)
139+
for {
140+
frame, more := frames.Next()
141+
file := frame.File
142+
if base != "" {
143+
file = strings.TrimPrefix(file, base)
120144
}
145+
_, funcName := splitFuncName(frame.Function)
121146
stack = append(stack, StackFrame{
122147
File: file,
123-
Line: line,
148+
Line: frame.Line,
124149
Func: funcName,
125150
})
126-
trace = append(trace, pc)
151+
if !more || len(stack) >= maxFrames {
152+
break
153+
}
127154
}
128-
c.frame = stack
129-
c.stack = trace
130155
return stack
131156
}
132157

133158
// Unwrap returns the immediate parent error for use with errors.Is and errors.As.
134-
func (c customError) Unwrap() error {
159+
func (c *customError) Unwrap() error {
135160
return c.wrapped
136161
}
137162

138-
func packageFuncName(pc uintptr) (string, string) {
139-
f := runtime.FuncForPC(pc)
140-
if f == nil {
141-
return "", ""
142-
}
143-
163+
// splitFuncName splits a fully qualified function name into package and function parts.
164+
func splitFuncName(qualifiedName string) (string, string) {
144165
packageName := ""
145-
funcName := f.Name()
166+
funcName := qualifiedName
146167

147168
if ind := strings.LastIndex(funcName, "/"); ind > 0 {
148169
packageName += funcName[:ind+1]
@@ -220,7 +241,9 @@ func WrapWithSkipAndStatus(err error, msg string, skip int, status *grpcstatus.S
220241
}
221242

222243
c.stack = e.Callers()
223-
c.frame = e.StackFrame()
244+
if ce, ok := e.(*customError); ok {
245+
c.basePath = ce.basePath
246+
}
224247
if n, ok := e.(NotifyExt); ok {
225248
c.shouldNotify = n.ShouldNotify()
226249
}
@@ -234,16 +257,17 @@ func WrapWithSkipAndStatus(err error, msg string, skip int, status *grpcstatus.S
234257
shouldNotify: true,
235258
status: status,
236259
}
237-
c.generateStack(skip + 1)
260+
c.captureStack(skip + 1)
238261
return c
239262

240263
}
241264

242265
// SetMaxStackDepth sets the maximum number of stack frames captured when creating errors.
243-
// Default is 64. Must be called during initialization.
266+
// Accepts values in [1, 256]; out-of-range values are ignored. Default is 16.
267+
// Safe for concurrent use.
244268
func SetMaxStackDepth(n int) {
245-
if n > 0 {
246-
maxStackDepth = n
269+
if n > 0 && n <= 256 {
270+
atomicStackDepth.Store(int32(n))
247271
}
248272
}
249273

@@ -261,6 +285,6 @@ func Wrapf(err error, format string, args ...any) ErrorExt {
261285
func SetBaseFilePath(path string) {
262286
path = strings.TrimSpace(path)
263287
if path != "" {
264-
basePath = path
288+
atomicBasePath.Store(path)
265289
}
266290
}

0 commit comments

Comments
 (0)