-
Notifications
You must be signed in to change notification settings - Fork 297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fxtest: Enforceable Timeouts on Lifecycle #1203
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -66,34 +66,86 @@ func (t *panicT) FailNow() { | |||||||||||||||||
panic("test lifecycle failed") | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// LifecycleOption modifies the behavior of the [Lifecycle] | ||||||||||||||||||
// when passed to [NewLifecycle]. | ||||||||||||||||||
type LifecycleOption interface { | ||||||||||||||||||
apply(*Lifecycle) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// EnforceTimeout will cause the [Lifecycle]'s Start and Stop methods | ||||||||||||||||||
// to return an error as soon as context expires, | ||||||||||||||||||
// regardless of whether specific hooks respect the timeout. | ||||||||||||||||||
func EnforceTimeout(enforce bool) LifecycleOption { | ||||||||||||||||||
return &enforceTimeout{ | ||||||||||||||||||
enforce: enforce, | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
type enforceTimeout struct { | ||||||||||||||||||
enforce bool | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
func (e *enforceTimeout) apply(lc *Lifecycle) { | ||||||||||||||||||
lc.enforceTimeout = e.enforce | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
var _ LifecycleOption = (*enforceTimeout)(nil) | ||||||||||||||||||
|
||||||||||||||||||
// Lifecycle is a testing spy for fx.Lifecycle. It exposes Start and Stop | ||||||||||||||||||
// methods (and some test-specific helpers) so that unit tests can exercise | ||||||||||||||||||
// hooks. | ||||||||||||||||||
type Lifecycle struct { | ||||||||||||||||||
t TB | ||||||||||||||||||
lc *lifecycle.Lifecycle | ||||||||||||||||||
|
||||||||||||||||||
enforceTimeout bool | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
var _ fx.Lifecycle = (*Lifecycle)(nil) | ||||||||||||||||||
|
||||||||||||||||||
// NewLifecycle creates a new test lifecycle. | ||||||||||||||||||
func NewLifecycle(t TB) *Lifecycle { | ||||||||||||||||||
func NewLifecycle(t TB, opts ...LifecycleOption) *Lifecycle { | ||||||||||||||||||
var w io.Writer | ||||||||||||||||||
if t != nil { | ||||||||||||||||||
w = testutil.WriteSyncer{T: t} | ||||||||||||||||||
} else { | ||||||||||||||||||
w = os.Stderr | ||||||||||||||||||
t = &panicT{W: os.Stderr} | ||||||||||||||||||
} | ||||||||||||||||||
return &Lifecycle{ | ||||||||||||||||||
lc := &Lifecycle{ | ||||||||||||||||||
lc: lifecycle.New(fxlog.DefaultLogger(w), fxclock.System), | ||||||||||||||||||
t: t, | ||||||||||||||||||
} | ||||||||||||||||||
for _, opt := range opts { | ||||||||||||||||||
opt.apply(lc) | ||||||||||||||||||
} | ||||||||||||||||||
return lc | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
func (l *Lifecycle) withTimeout(ctx context.Context, fn func(context.Context) error) error { | ||||||||||||||||||
if !l.enforceTimeout { | ||||||||||||||||||
return fn(ctx) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
c := make(chan error, 1) | ||||||||||||||||||
JacobOaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
go func() { | ||||||||||||||||||
c <- fn(ctx) | ||||||||||||||||||
}() | ||||||||||||||||||
Comment on lines
+136
to
+138
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One last thing: Just for the sake of easier/faster cleanup, we should wrap the context with a WithCancel here so that when this function returns because of a timeout, the context that
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, shouldn't the context that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤔 You're right. My mistake. |
||||||||||||||||||
|
||||||||||||||||||
var err error | ||||||||||||||||||
select { | ||||||||||||||||||
case err = <-c: | ||||||||||||||||||
case <-ctx.Done(): | ||||||||||||||||||
err = ctx.Err() | ||||||||||||||||||
} | ||||||||||||||||||
return err | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// Start executes all registered OnStart hooks in order, halting at the first | ||||||||||||||||||
// hook that doesn't succeed. | ||||||||||||||||||
func (l *Lifecycle) Start(ctx context.Context) error { return l.lc.Start(ctx) } | ||||||||||||||||||
func (l *Lifecycle) Start(ctx context.Context) error { | ||||||||||||||||||
return l.withTimeout(ctx, l.lc.Start) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// RequireStart calls Start with context.Background(), failing the test if an | ||||||||||||||||||
// error is encountered. | ||||||||||||||||||
|
@@ -114,7 +166,9 @@ func (l *Lifecycle) RequireStart() *Lifecycle { | |||||||||||||||||
// If any hook returns an error, execution continues for a best-effort | ||||||||||||||||||
// cleanup. Any errors encountered are collected into a single error and | ||||||||||||||||||
// returned. | ||||||||||||||||||
func (l *Lifecycle) Stop(ctx context.Context) error { return l.lc.Stop(ctx) } | ||||||||||||||||||
func (l *Lifecycle) Stop(ctx context.Context) error { | ||||||||||||||||||
return l.withTimeout(ctx, l.lc.Stop) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// RequireStop calls Stop with context.Background(), failing the test if an error | ||||||||||||||||||
// is encountered. | ||||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Strictly speaking, this is considered a breaking change per Go compatibility guarantees.
Adding variadic options to a function that didn't previously accept them still changes the signature.
It breaks cases like passing a reference to the function, for example.
I'll defer to y'all about whether you'd like to take the risk, since passing
fastest.NewLifecycle
would be a pretty niche use.Alternatively, we can add one of
NewLifecycleWith
,NewLifecycleWithOptions
, orNewStrictLifecycle
instead. (The last defaulting to enforceTimeout = true. In that case, we could drop the EnforceTimeout option completely, and instead just addOption
s for future expansion.)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah - makes sense. I did consider that it is technically a breaking change, but I'm assuming that the impact radius is minimal/non-existent. To justify this assumption, I did run Uber's entire unit test suite with this PR and did not see any issues, so I'm personally comfortable with this change to avoid adding multiple
NewLifecycleX
APIs. Especially since any issues that do exist will (or at least, should) be isolated to test code.That said, I'm open to adding a new API instead if somebody feels strongly about this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't feel strongly about this; will defer to other maintainers.
I think this is fine as-is. Passing
fxtest.NewLifecycle
as a function parameter is a niche enough use case that I expect zero breakage.This isn't authoritative, but just searching public code on GitHub, there are no references to
fxtest.NewLifecycle
not followed by a(
, so they're always invocations, and won't be broken.https://github.com/search?q=%2Ffxtest%5C.NewLifecycle%5B%5E%5C%28%5D%2F+path%3A%2F.go%24%2F&type=code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 @sywhang or @tchung1118 - do you have strong opinions on this? Otherwise I will merge this as-is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is fine as-is, too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a strong opinion against this either but I do prefer making a slightly breaking change in this API rather than overloading the API surface with a variant of
NewLifecycle
given how niche the set of use cases that break from this.