Skip to content

Commit f96dfda

Browse files
committed
Add stack package for managing error stack traces
Signed-off-by: Derek McGowan <[email protected]>
1 parent 70fd2d7 commit f96dfda

2 files changed

Lines changed: 394 additions & 0 deletions

File tree

stack/stack.go

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package stack
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"os"
25+
"path"
26+
"runtime"
27+
"strings"
28+
"sync/atomic"
29+
"unsafe"
30+
31+
"github.com/containerd/typeurl/v2"
32+
33+
"github.com/containerd/errdefs/internal/types"
34+
)
35+
36+
func init() {
37+
typeurl.Register((*stack)(nil), "github.com/containerd/errdefs", "stack+json")
38+
}
39+
40+
var (
41+
// Version is version of running process
42+
Version string = "dev"
43+
44+
// Revision is the specific revision of the running process
45+
Revision string = "dirty"
46+
)
47+
48+
type stack struct {
49+
decoded *Trace
50+
51+
callers []uintptr
52+
helpers []uintptr
53+
}
54+
55+
// Trace is a stack trace along with process information about the source
56+
type Trace struct {
57+
Version string `json:"version,omitempty"`
58+
Revision string `json:"revision,omitempty"`
59+
Cmdline []string `json:"cmdline,omitempty"`
60+
Frames []Frame `json:"frames,omitempty"`
61+
Pid int32 `json:"pid,omitempty"`
62+
}
63+
64+
// Frame is a single frame of the trace representing a line of code
65+
type Frame struct {
66+
Name string `json:"Name,omitempty"`
67+
File string `json:"File,omitempty"`
68+
Line int32 `json:"Line,omitempty"`
69+
}
70+
71+
func (f Frame) Format(s fmt.State, verb rune) {
72+
switch verb {
73+
case 'v':
74+
switch {
75+
case s.Flag('+'):
76+
fmt.Fprintf(s, "%s\n\t%s:%d\n", f.Name, f.File, f.Line)
77+
default:
78+
fmt.Fprint(s, f.Name)
79+
}
80+
case 's':
81+
fmt.Fprint(s, path.Base(f.Name))
82+
case 'q':
83+
fmt.Fprintf(s, "%q", path.Base(f.Name))
84+
}
85+
}
86+
87+
// callers returns the current stack, skipping over the number of frames mentioned
88+
// Frames with skip=0:
89+
//
90+
// frame[0] runtime.Callers
91+
// frame[1] <this function> github.com/containerd/errdefs/stack.callers
92+
// frame[2] <caller> (Use skip=2 to have this be first frame)
93+
func callers(skip int) *stack {
94+
const depth = 32
95+
var pcs [depth]uintptr
96+
n := runtime.Callers(skip, pcs[:])
97+
return &stack{
98+
callers: pcs[0:n],
99+
}
100+
}
101+
102+
func (s *stack) getDecoded() *Trace {
103+
if s.decoded == nil {
104+
var unsafeDecoded = (*unsafe.Pointer)(unsafe.Pointer(&s.decoded))
105+
106+
var helpers map[string]struct{}
107+
if len(s.helpers) > 0 {
108+
helpers = make(map[string]struct{})
109+
frames := runtime.CallersFrames(s.helpers)
110+
for {
111+
frame, more := frames.Next()
112+
helpers[frame.Function] = struct{}{}
113+
if !more {
114+
break
115+
}
116+
}
117+
}
118+
119+
f := make([]Frame, 0, len(s.callers))
120+
if len(s.callers) > 0 {
121+
frames := runtime.CallersFrames(s.callers)
122+
for {
123+
frame, more := frames.Next()
124+
if _, ok := helpers[frame.Function]; !ok {
125+
f = append(f, Frame{
126+
Name: frame.Function,
127+
File: frame.File,
128+
Line: int32(frame.Line),
129+
})
130+
}
131+
if !more {
132+
break
133+
}
134+
}
135+
}
136+
137+
t := Trace{
138+
Version: Version,
139+
Revision: Revision,
140+
Cmdline: os.Args,
141+
Frames: f,
142+
Pid: int32(os.Getpid()),
143+
}
144+
145+
atomic.StorePointer(unsafeDecoded, unsafe.Pointer(&t))
146+
}
147+
148+
return s.decoded
149+
}
150+
151+
func (s *stack) Error() string {
152+
return fmt.Sprintf("%+v", s.getDecoded())
153+
}
154+
155+
func (s *stack) MarshalJSON() ([]byte, error) {
156+
return json.Marshal(s.getDecoded())
157+
}
158+
159+
func (s *stack) UnmarshalJSON(b []byte) error {
160+
var unsafeDecoded = (*unsafe.Pointer)(unsafe.Pointer(&s.decoded))
161+
var t Trace
162+
163+
if err := json.Unmarshal(b, &t); err != nil {
164+
return err
165+
}
166+
167+
atomic.StorePointer(unsafeDecoded, unsafe.Pointer(&t))
168+
169+
return nil
170+
}
171+
172+
func (s *stack) Format(st fmt.State, verb rune) {
173+
switch verb {
174+
case 'v':
175+
if st.Flag('+') {
176+
t := s.getDecoded()
177+
fmt.Fprintf(st, "%d %s %s\n", t.Pid, t.Version, strings.Join(t.Cmdline, " "))
178+
for _, f := range t.Frames {
179+
f.Format(st, verb)
180+
}
181+
fmt.Fprintln(st)
182+
return
183+
}
184+
}
185+
}
186+
187+
func (s *stack) StackTrace() Trace {
188+
return *s.getDecoded()
189+
}
190+
191+
func (s *stack) CollapseError() {}
192+
193+
// ErrStack returns a new error for the callers stack,
194+
// this can be wrapped or joined into an existing error.
195+
// NOTE: When joined with errors.Join, the stack
196+
// will show up in the error string output.
197+
// Use with `stack.Join` to force addition of the
198+
// error stack.
199+
func ErrStack() error {
200+
return callers(3)
201+
}
202+
203+
// Join adds a stack if there is no stack included to the errors
204+
// and returns a joined error with the stack hidden from the error
205+
// output. The stack error shows up when Unwrapped or formatted
206+
// with `%+v`.
207+
func Join(errs ...error) error {
208+
return joinErrors(nil, errs)
209+
}
210+
211+
// WithStack will check if the error already has a stack otherwise
212+
// return a new error with the error joined with a stack error
213+
// Any helpers will be skipped.
214+
func WithStack(ctx context.Context, errs ...error) error {
215+
return joinErrors(ctx.Value(helperKey{}), errs)
216+
}
217+
218+
func joinErrors(helperVal any, errs []error) error {
219+
var filtered []error
220+
var collapsible []error
221+
var hasStack bool
222+
for _, err := range errs {
223+
if err != nil {
224+
if !hasStack && hasLocalStackTrace(err) {
225+
hasStack = true
226+
}
227+
if _, ok := err.(types.CollapsibleError); ok {
228+
collapsible = append(collapsible, err)
229+
} else {
230+
filtered = append(filtered, err)
231+
}
232+
233+
}
234+
}
235+
if len(filtered) == 0 {
236+
return nil
237+
}
238+
if !hasStack {
239+
s := callers(4)
240+
if helpers, ok := helperVal.([]uintptr); ok {
241+
s.helpers = helpers
242+
}
243+
collapsible = append(collapsible, s)
244+
}
245+
var err error
246+
if len(filtered) > 1 {
247+
err = errors.Join(filtered...)
248+
} else {
249+
err = filtered[0]
250+
}
251+
if len(collapsible) == 0 {
252+
return err
253+
}
254+
255+
return types.CollapsedError(err, collapsible...)
256+
}
257+
258+
func hasLocalStackTrace(err error) bool {
259+
switch e := err.(type) {
260+
case *stack:
261+
return true
262+
case interface{ Unwrap() error }:
263+
if hasLocalStackTrace(e.Unwrap()) {
264+
return true
265+
}
266+
case interface{ Unwrap() []error }:
267+
for _, ue := range e.Unwrap() {
268+
if hasLocalStackTrace(ue) {
269+
return true
270+
}
271+
}
272+
}
273+
274+
// TODO: Consider if pkg/errors compatibility is needed
275+
// NOTE: This was implemented before the standard error package
276+
// so it may unwrap and have this interface.
277+
//if _, ok := err.(interface{ StackTrace() pkgerrors.StackTrace }); ok {
278+
// return true
279+
//}
280+
281+
return false
282+
}
283+
284+
type helperKey struct{}
285+
286+
// WithHelper marks the context as from a helper function
287+
// This will add an additional skip to the error stack trace
288+
func WithHelper(ctx context.Context) context.Context {
289+
helpers, _ := ctx.Value(helperKey{}).([]uintptr)
290+
var pcs [1]uintptr
291+
n := runtime.Callers(2, pcs[:])
292+
if n == 1 {
293+
ctx = context.WithValue(ctx, helperKey{}, append(helpers, pcs[0]))
294+
}
295+
return ctx
296+
}

stack/stack_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package stack
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"strings"
24+
"testing"
25+
)
26+
27+
func TestStack(t *testing.T) {
28+
s := callers(2)
29+
if len(s.callers) == 0 {
30+
t.Fatalf("expected callers, got:\n%v", s)
31+
}
32+
tr := s.getDecoded()
33+
if len(tr.Frames) != len(s.callers) {
34+
t.Fatalf("expected 1 frame, got %d", len(tr.Frames))
35+
}
36+
if name := tr.Frames[0].Name; !strings.HasSuffix(name, "."+t.Name()) {
37+
t.Fatalf("unexpected frame: %s\n%v", name, s)
38+
}
39+
}
40+
41+
func TestCollapsed(t *testing.T) {
42+
checkError := func(err error, expected string) {
43+
t.Helper()
44+
if err.Error() != expected {
45+
t.Fatalf("unexpected error string %q, expected %q", err.Error(), expected)
46+
}
47+
48+
if printed := fmt.Sprintf("%v", err); printed != expected {
49+
t.Fatalf("unexpected error string %q, expected %q", printed, expected)
50+
}
51+
52+
if printed := fmt.Sprintf("%+v", err); !strings.HasPrefix(printed, expected) || !strings.Contains(printed, t.Name()) {
53+
t.Fatalf("unexpected error string %q, expected %q with stack containing %q", printed, expected, t.Name())
54+
}
55+
}
56+
expected := "some error"
57+
checkError(Join(errors.New(expected)), expected)
58+
checkError(Join(errors.New(expected), ErrStack()), expected)
59+
checkError(WithStack(context.Background(), errors.New(expected)), expected)
60+
}
61+
62+
func TestHelpers(t *testing.T) {
63+
checkError := func(err error, expected string, withHelper bool) {
64+
t.Helper()
65+
if err.Error() != expected {
66+
t.Fatalf("unexpected error string %q, expected %q", err.Error(), expected)
67+
}
68+
69+
if printed := fmt.Sprintf("%v", err); printed != expected {
70+
t.Fatalf("unexpected error string %q, expected %q", printed, expected)
71+
}
72+
73+
printed := fmt.Sprintf("%+v", err)
74+
if !strings.HasPrefix(printed, expected) || !strings.Contains(printed, t.Name()) {
75+
t.Fatalf("unexpected error string %q, expected %q with stack containing %q", printed, expected, t.Name())
76+
}
77+
if withHelper {
78+
if !strings.Contains(printed, "testHelper") {
79+
t.Fatalf("unexpected error string, expected stack containing testHelper:\n%s", printed)
80+
}
81+
} else if strings.Contains(printed, "testHelper") {
82+
t.Fatalf("unexpected error string, expected stack with no containing testHelper:\n%s", printed)
83+
}
84+
}
85+
expected := "some error"
86+
checkError(Join(errors.New(expected)), expected, false)
87+
checkError(testHelper(expected, false), expected, true)
88+
checkError(testHelper(expected, true), expected, false)
89+
}
90+
91+
func testHelper(msg string, withHelper bool) error {
92+
if withHelper {
93+
return WithStack(WithHelper(context.Background()), errors.New(msg))
94+
} else {
95+
return WithStack(context.Background(), errors.New(msg))
96+
}
97+
98+
}

0 commit comments

Comments
 (0)