diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb2a3f6..d5e3d85a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ migrate tests from prior versions. - Add `TimeAdjuster` interface, for use with `AdvanceTime()` - Add `engine.EnableHandler()` - Add `Test.EnableHandlers()` and `DisableHandlers()` +- Add `panicx.Location` - **[BC]** Add `TestingT.Failed()`, `Fatal()` and `Helper()` methods ### Changed @@ -37,6 +38,7 @@ migrate tests from prior versions. - **[BC]** `engine.New()` and `MustNew()` now accept `configkit.RichApplication` (previously `dogma.Application`) - **[BC]** Rename `WithStartTime()` to `StartTimeAt()` - **[BC]** Rename `WithOperationOptions()` to `WithUnsafeOperationOptions()` +- **[BC]** Move `controller.ConvertUnexpectedMessagePanic()` to `panicx.EnrichUnexpectedMessage()` ### Removed diff --git a/engine/controller/aggregate/controller.go b/engine/controller/aggregate/controller.go index f462ee0e..2568cf00 100644 --- a/engine/controller/aggregate/controller.go +++ b/engine/controller/aggregate/controller.go @@ -7,9 +7,9 @@ import ( "github.com/dogmatiq/configkit" "github.com/dogmatiq/configkit/message" - "github.com/dogmatiq/testkit/engine/controller" "github.com/dogmatiq/testkit/engine/envelope" "github.com/dogmatiq/testkit/engine/fact" + "github.com/dogmatiq/testkit/engine/panicx" ) // Controller is an implementation of engine.Controller for @@ -46,7 +46,7 @@ func (c *Controller) Handle( env.Role.MustBe(message.CommandRole) var id string - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "AggregateMessageHandler", "RouteCommandToInstance", @@ -75,7 +75,7 @@ func (c *Controller) Handle( if exists { for _, env := range history { - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "AggregateRoot", "ApplyEvent", @@ -111,7 +111,7 @@ func (c *Controller) Handle( command: env, } - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "AggregateMessageHandler", "HandleCommand", diff --git a/engine/controller/aggregate/scope.go b/engine/controller/aggregate/scope.go index ceec34b0..5d27d630 100644 --- a/engine/controller/aggregate/scope.go +++ b/engine/controller/aggregate/scope.go @@ -6,9 +6,9 @@ import ( "github.com/dogmatiq/configkit" "github.com/dogmatiq/dogma" - "github.com/dogmatiq/testkit/engine/controller" "github.com/dogmatiq/testkit/engine/envelope" "github.com/dogmatiq/testkit/engine/fact" + "github.com/dogmatiq/testkit/engine/panicx" ) // scope is an implementation of dogma.AggregateCommandScope. @@ -72,7 +72,7 @@ func (s *scope) RecordEvent(m dogma.Message) { s.exists = true } - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( s.config, "AggregateRoot", "ApplyEvent", diff --git a/engine/controller/integration/controller.go b/engine/controller/integration/controller.go index 3bf877b5..7d84c475 100644 --- a/engine/controller/integration/controller.go +++ b/engine/controller/integration/controller.go @@ -6,9 +6,9 @@ import ( "github.com/dogmatiq/configkit" "github.com/dogmatiq/configkit/message" - "github.com/dogmatiq/testkit/engine/controller" "github.com/dogmatiq/testkit/engine/envelope" "github.com/dogmatiq/testkit/engine/fact" + "github.com/dogmatiq/testkit/engine/panicx" ) // Controller is an implementation of engine.Controller for @@ -43,7 +43,7 @@ func (c *Controller) Handle( env.Role.MustBe(message.CommandRole) var t time.Duration - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "IntegrationMessageHandler", "TimeoutHint", @@ -68,7 +68,7 @@ func (c *Controller) Handle( } var err error - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "IntegrationMessageHandler", "HandleCommand", diff --git a/engine/controller/panic.go b/engine/controller/panic.go deleted file mode 100644 index cf63c532..00000000 --- a/engine/controller/panic.go +++ /dev/null @@ -1,96 +0,0 @@ -package controller - -import ( - "runtime" - "strings" - - "github.com/dogmatiq/configkit" - "github.com/dogmatiq/dogma" -) - -// UnexpectedMessage is a panic value that provides more context when a handler -// panics with a dogma.UnexpoectedMessage value. -type UnexpectedMessage struct { - // Handler is the handler that panicked. - Handler configkit.RichHandler - - // Interface is the name of the interface containing the method that the - // controller called resulting in the panic. - Interface string - - // Method is the name of the method that the controller called resulting in - // the panic. - Method string - - // Message is the message that caused the handler to panic. - Message dogma.Message - - // PanicFunc is the name of the function that panicked, if known. - PanicFunc string - - // PanicFile is the name of the file where the panic originated, if known. - PanicFile string - - // PanicLine is the line number within the file where the panic originated, - // if known. - PanicLine int -} - -// ConvertUnexpectedMessagePanic calls fn() and converts dogma.UnexpectedMessage -// values to an controller.UnexpectedMessage value to provide more context about -// the failure. -func ConvertUnexpectedMessagePanic( - h configkit.RichHandler, - iface, method string, - m dogma.Message, - fn func(), -) { - defer func() { - v := recover() - - if v == nil { - return - } - - if v == dogma.UnexpectedMessage { - name, file, line := findPanicSite() - - v = UnexpectedMessage{ - Handler: h, - Interface: iface, - Method: method, - Message: m, - PanicFunc: name, - PanicFile: file, - PanicLine: line, - } - } - - panic(v) - }() - - fn() -} - -func findPanicSite() (string, string, int) { - var ( - name, file string - line int - pc [16]uintptr - ) - - n := runtime.Callers(3, pc[:]) - for _, pc := range pc[:n] { - fn := runtime.FuncForPC(pc) - - if fn != nil { - name = fn.Name() - if !strings.HasPrefix(name, "runtime.") { - file, line = fn.FileLine(pc) - break - } - } - } - - return name, file, line -} diff --git a/engine/controller/process/controller.go b/engine/controller/process/controller.go index 92e03287..e326f284 100644 --- a/engine/controller/process/controller.go +++ b/engine/controller/process/controller.go @@ -9,9 +9,9 @@ import ( "github.com/dogmatiq/configkit" "github.com/dogmatiq/configkit/message" "github.com/dogmatiq/dogma" - "github.com/dogmatiq/testkit/engine/controller" "github.com/dogmatiq/testkit/engine/envelope" "github.com/dogmatiq/testkit/engine/fact" + "github.com/dogmatiq/testkit/engine/panicx" ) // Controller is an implementation of engine.Controller for @@ -66,7 +66,7 @@ func (c *Controller) Handle( env.Role.MustBe(message.EventRole, message.TimeoutRole) var t time.Duration - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "ProcessMessageHandler", "TimeoutHint", @@ -169,7 +169,7 @@ func (c *Controller) routeEvent( ok bool err error ) - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "ProcessMessageHandler", "RouteEventToInstance", @@ -230,7 +230,7 @@ func (c *Controller) handle(ctx context.Context, s *scope) error { } var err error - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "ProcessMessageHandler", method, diff --git a/engine/controller/projection/controller.go b/engine/controller/projection/controller.go index 4d55c755..b667b01e 100644 --- a/engine/controller/projection/controller.go +++ b/engine/controller/projection/controller.go @@ -6,9 +6,9 @@ import ( "github.com/dogmatiq/configkit" "github.com/dogmatiq/configkit/message" - "github.com/dogmatiq/testkit/engine/controller" "github.com/dogmatiq/testkit/engine/envelope" "github.com/dogmatiq/testkit/engine/fact" + "github.com/dogmatiq/testkit/engine/panicx" ) // CompactInterval is how frequently projections should be compacted. @@ -76,7 +76,7 @@ func (c *Controller) Handle( handler := c.Config.Handler() var t time.Duration - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "ProjectionMessageHandler", "TimeoutHint", @@ -145,7 +145,7 @@ func (c *Controller) Handle( } var ok bool - controller.ConvertUnexpectedMessagePanic( + panicx.EnrichUnexpectedMessage( c.Config, "ProjectionMessageHandler", "HandleEvent", diff --git a/engine/panicx/doc.go b/engine/panicx/doc.go new file mode 100644 index 00000000..c8d408d8 --- /dev/null +++ b/engine/panicx/doc.go @@ -0,0 +1,3 @@ +// Package panicx contains utilities for providing meaningful contexts to panics +// that occur with the engine. +package panicx diff --git a/engine/panicx/ginkgo_test.go b/engine/panicx/ginkgo_test.go new file mode 100644 index 00000000..2c2499a2 --- /dev/null +++ b/engine/panicx/ginkgo_test.go @@ -0,0 +1,15 @@ +package panicx_test + +import ( + "reflect" + "testing" + + "github.com/onsi/ginkgo" + "github.com/onsi/gomega" +) + +func TestSuite(t *testing.T) { + type tag struct{} + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, reflect.TypeOf(tag{}).PkgPath()) +} diff --git a/engine/panicx/location.go b/engine/panicx/location.go new file mode 100644 index 00000000..4a8581f4 --- /dev/null +++ b/engine/panicx/location.go @@ -0,0 +1,115 @@ +package panicx + +import ( + "fmt" + "path" + "runtime" + "strings" +) + +// Location describes a location within the codebase. +type Location struct { + Func string + File string + Line int +} + +func (l Location) String() string { + if l.Func != "" && l.File != "" { + return fmt.Sprintf( + "%s() %s:%d", + l.Func, + l.File, //path.Base(l.File), + l.Line, + ) + } + + if l.Func != "" { + return fmt.Sprintf("%s()", l.Func) + } + + if l.File != "" { + return fmt.Sprintf( + "%s:%d", + path.Base(l.File), + l.Line, + ) + } + + return "" +} + +// LocationOfPanic returns the location of the call to panic() that caused the +// stack to start unwinding. +// +// It must be called within a deferred function and only if recover() returned a +// non-nil value. Otherwise the behavior of the function is undefined. +func LocationOfPanic() Location { + // During a panic() the runtime *adds* frames for each deferred function, so + // the function that caused the panic is still on the stack, even though it + // is unwinding. + // + // See https://github.com/golang/go/issues/26275 + // See https://github.com/golang/go/issues/26320 + + var loc Location + foundPanicCall := false + + eachFrame( + func(fr runtime.Frame) bool { + if fr.Function == "runtime.gopanic" { + // We found the call to runtime.gopanic(), which is the internal + // implementation of panic(). + // + // That means that the next function we find that's NOT in the + // "runtime" package is the function that called panic(). + foundPanicCall = true + return true + } + + if strings.HasPrefix(fr.Function, "runtime.") { + // We found some other function within the runtime package, we + // keep looking for some user-land code. + return true + } + + // We found some user-land code. If we haven't found the internal + // call to runtime.gopanic() that means we're still iterating + // through the frames from inside the defer() so we keep searching. + if !foundPanicCall { + return true + } + + // Otherwise we've found the function that called panic(). + loc = Location{ + Func: fr.Function, + File: fr.File, + Line: fr.Line, + } + + return false + }, + ) + + return loc +} + +// eachFrame calls fn for each frame on the call stack until fn returns false. +func eachFrame(fn func(fr runtime.Frame) bool) { + var pointers [8]uintptr + var skip int + + for { + count := runtime.Callers(skip, pointers[:]) + iter := runtime.CallersFrames(pointers[:count]) + skip += count + + for { + fr, _ := iter.Next() + + if fr.PC == 0 || !fn(fr) { + return + } + } + } +} diff --git a/engine/panicx/location_test.go b/engine/panicx/location_test.go new file mode 100644 index 00000000..79c7f59b --- /dev/null +++ b/engine/panicx/location_test.go @@ -0,0 +1,23 @@ +package panicx_test + +import ( + . "github.com/dogmatiq/testkit/engine/panicx" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +var _ = Describe("type Location", func() { + Describe("func String()", func() { + DescribeTable( + "it returns the expectation string", + func(s string, l Location) { + Expect(l.String()).To(Equal(s)) + }, + Entry("empty", "", Location{}), + Entry("function name only", "()", Location{Func: ""}), + Entry("file location only", ":123", Location{File: "", Line: 123}), + Entry("both", "() :123", Location{Func: "", File: "", Line: 123}), + ) + }) +}) diff --git a/engine/panicx/unexpectedmessage.go b/engine/panicx/unexpectedmessage.go new file mode 100644 index 00000000..e91b60aa --- /dev/null +++ b/engine/panicx/unexpectedmessage.go @@ -0,0 +1,71 @@ +package panicx + +import ( + "fmt" + + "github.com/dogmatiq/configkit" + "github.com/dogmatiq/dogma" +) + +// UnexpectedMessage is a panic value that provides more context when a handler +// panics with a dogma.UnexpoectedMessage value. +type UnexpectedMessage struct { + // Handler is the handler that panicked. + Handler configkit.RichHandler + + // Interface is the name of the interface containing the method that the + // controller called resulting in the panic. + Interface string + + // Method is the name of the method that the controller called resulting in + // the panic. + Method string + + // Message is the message that caused the handler to panic. + Message dogma.Message + + // PanicLocation is the location of the function that panicked, if known. + PanicLocation Location +} + +func (x UnexpectedMessage) String() string { + return fmt.Sprintf( + "the '%s' %s message handler did not expect %s() to be called with a message of type %T", + x.Handler.Identity().Name, + x.Handler.HandlerType(), + x.Method, + x.Message, + ) +} + +// EnrichUnexpectedMessage calls fn() and converts dogma.UnexpectedMessage +// values to an controller.UnexpectedMessage value to provide more context about +// the failure. +func EnrichUnexpectedMessage( + h configkit.RichHandler, + iface, method string, + m dogma.Message, + fn func(), +) { + defer func() { + v := recover() + + if v == nil { + return + } + + if v == dogma.UnexpectedMessage { + v = UnexpectedMessage{ + Handler: h, + Interface: iface, + Method: method, + Message: m, + PanicLocation: LocationOfPanic(), + } + } + + panic(v) + }() + + fn() +} diff --git a/engine/controller/panic_test.go b/engine/panicx/unexpectedmessage_test.go similarity index 50% rename from engine/controller/panic_test.go rename to engine/panicx/unexpectedmessage_test.go index ba8ed826..ddbd314e 100644 --- a/engine/controller/panic_test.go +++ b/engine/panicx/unexpectedmessage_test.go @@ -1,16 +1,51 @@ -package controller_test +package panicx_test import ( "github.com/dogmatiq/configkit" "github.com/dogmatiq/dogma" . "github.com/dogmatiq/dogma/fixtures" - . "github.com/dogmatiq/testkit/engine/controller" + . "github.com/dogmatiq/testkit/engine/panicx" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" . "github.com/onsi/gomega/gstruct" ) -var _ = Describe("func ConvertUnexpectedMessagePanic()", func() { +var _ = Describe("type UnexpectedMessage", func() { + config := configkit.FromProjection( + &ProjectionMessageHandler{ + ConfigureFunc: func(c dogma.ProjectionConfigurer) { + c.Identity("", "") + c.ConsumesEventType(MessageE{}) + }, + }, + ) + + Describe("func String()", func() { + It("returns a description of the panic", func() { + defer func() { + r := recover() + Expect(r).To(BeAssignableToTypeOf(UnexpectedMessage{})) + + x := r.(UnexpectedMessage) + Expect(x.String()).To(Equal( + "the '' projection message handler did not expect () to be called with a message of type fixtures.MessageA", + )) + }() + + EnrichUnexpectedMessage( + config, + "", + "", + MessageA1, + func() { + panic(dogma.UnexpectedMessage) + }, + ) + }) + }) +}) + +var _ = Describe("func EnrichUnexpectedMessage()", func() { config := configkit.FromProjection( &ProjectionMessageHandler{ ConfigureFunc: func(c dogma.ProjectionConfigurer) { @@ -23,7 +58,7 @@ var _ = Describe("func ConvertUnexpectedMessagePanic()", func() { It("calls the function", func() { called := false - ConvertUnexpectedMessagePanic( + EnrichUnexpectedMessage( config, "", "", @@ -38,7 +73,7 @@ var _ = Describe("func ConvertUnexpectedMessagePanic()", func() { It("propagates panic values", func() { Expect(func() { - ConvertUnexpectedMessagePanic( + EnrichUnexpectedMessage( config, "", "", @@ -52,7 +87,7 @@ var _ = Describe("func ConvertUnexpectedMessagePanic()", func() { It("converts UnexpectedMessage values", func() { Expect(func() { - ConvertUnexpectedMessagePanic( + EnrichUnexpectedMessage( config, "", "", @@ -68,9 +103,13 @@ var _ = Describe("func ConvertUnexpectedMessagePanic()", func() { "Interface": Equal(""), "Method": Equal(""), "Message": Equal(MessageA1), - "PanicFunc": Not(BeEmpty()), - "PanicFile": Not(BeEmpty()), - "PanicLine": Not(BeZero()), + "PanicLocation": MatchAllFields( + Fields{ + "Func": Not(BeEmpty()), + "File": HaveSuffix("/unexpectedmessage_test.go"), + "Line": Equal(96), + }, + ), }, ), ))