Skip to content
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

proposal: os/signal: Provide a new error type to communicate the signal triggered context cancellation #60756

Open
fishy opened this issue Jun 12, 2023 · 18 comments
Labels
Milestone

Comments

@fishy
Copy link

fishy commented Jun 12, 2023

This comes from the need to fix #60733.

In order to provide the information of which signal triggered context cancellation in signal.NotifyContext using context.Cause, we need a new error type (as that's the only type allowed by context cause) to communicate that info back to the user.

The proposal is to define it as a simple struct that can only be used by pointer as an error:

// CanceledBySignalError represents the error can be retrieved via context.Cause
// on the context returned by NotifyContext to inspect which signal caused the
// cancellation.
type CanceledBySignalError struct {
	// The signal caused the context cancellation.
	Signal os.Signal
}

func (cse *CanceledBySignalError) Error() string {
	return fmt.Sprintf("context canceled by signal %v", cse.Signal)
}

So users can inspect the signal via code like:

select {
case <-ctx.Done():
  cause := context.Cause(ctx)
  var cse *signal.CanceledBySignalError
  if errors.As(cause, &cse) {
    sig := cse.Signal
    // Do something with sig
  }
}
@fishy fishy added the Proposal label Jun 12, 2023
@gopherbot gopherbot added this to the Proposal milestone Jun 12, 2023
@bcmills
Copy link
Contributor

bcmills commented Jun 13, 2023

I think this should be named os.SignalError instead of signal.CanceledBySignalError, so that it may be used for cases beyond just signal.Notify. For example, I could envision a caller transforming an exec.ExitError by checking whether its ProcessState indicates termination by signal and returning a SignalError.

Actually, SignalError would be even more useful if exec.ExitError had a convenience Is method for it:

	err := cmd.Wait()
	if errors.Is(err, os.SignalError{Signal: syscall.SIGABRT}) {
		// err indicates termination due to signal SIGABRT — program probably failed an assertion
		…
	}

@fishy
Copy link
Author

fishy commented Jun 13, 2023

@bcmills I agree that os.SignalError is a better name, but I have a few comments/questions regarding that approach:

  1. The error message of it becomes ambiguous if it's used beyond signal.NotifyContext, e.g. it's no longer necessarily "context canceled by signal ...", I guess we can have a more generic message there and use fmt.Errorf("context canceled by %w", &os.SignalError{...}) in signal.NotifyContext
  2. I feel that using errors.Is on it opens some cans of worms we probably don't want to deal with. os.Signal is an interface so there's no guarantee that it will be comparable, and I think in general we prefer pointer errors when the error is a struct to make sure it's comparable (because in that case we only need to compare the pointer address).

But I think this can work if we use a generic error message, and change the errors.Is to errors.As in your example.

@bcmills
Copy link
Contributor

bcmills commented Jun 14, 2023

os.Signal is an interface so there's no guarantee that it will be comparable

In practice it always is:

~/go/src$ git grep 'func .* Signal() {' .
cmd/vendor/golang.org/x/sys/plan9/syscall_plan9.go:func (n Note) Signal() {}
cmd/vendor/golang.org/x/sys/windows/syscall_windows.go:func (s Signal) Signal() {}
sync/cond.go:func (c *Cond) Signal() {
syscall/syscall_js.go:func (s Signal) Signal() {}
syscall/syscall_plan9.go:func (n Note) Signal() {}
syscall/syscall_unix.go:func (s Signal) Signal() {}
syscall/syscall_wasip1.go:func (s Signal) Signal() {}
syscall/syscall_windows.go:func (s Signal) Signal() {}

syscall.Signal and windows.Signal are each always an integer type when defined. syscall.Note and plan9.Note are string types.

sync.Cond isn't intended to implement os.Signal at all, and doesn't today because it lacks a String method.

So I'm not worried about somebody stuffing an incomparable os.Signal into a SignalError.

@fishy
Copy link
Author

fishy commented Jun 14, 2023

I believe that when you use errors.Is(err, os.SignalError{Signal: syscall.SIGABRT}) and err indeed unwraps to os.SignalError type, you need both the unwrapped error and the target to be comparable to not panic. Here you can control the target to be comparable (as you know syscall.SIGABRT is comparable), you have no control what the err is and it's always comparable, so this is still a hanging knife that could panic the code unexpectly.

^ wrong, see https://go.dev/play/p/qpyJXU05CFl

@bcmills
Copy link
Contributor

bcmills commented Jun 14, 2023

@fishy
Copy link
Author

fishy commented Jun 14, 2023

hmm you are right. tested on playground: https://go.dev/play/p/qpyJXU05CFl

but another issue with making the nonpointer type to implement error is that people often don't know which type to use with errors.As, for example they can make this mistake:

var se *os.SignalError // they thought it would be the pointer type
if errors.As(err, &se) {
  // they'd never get here
}

the solution to that is to also implement os.SignalError.As to support both pointer and non-pointer types, but is that something we consider worth doing here?

@bcmills
Copy link
Contributor

bcmills commented Jun 14, 2023

To be clear, I think it would be ok for the type implementing error to be *SignalError.

(exec.ExitError.Is could follow the pointer explicitly.)

@fishy
Copy link
Author

fishy commented Jun 14, 2023

But if we use pointer type, errors.Is will no longer work as you expected here, because it will only return true if the pointers are the same, not the contents: https://go.dev/play/p/VNQ-iy4vA_2

@bcmills
Copy link
Contributor

bcmills commented Jun 14, 2023

The error type can provide an explicit Is method. 🙃
https://go.dev/play/p/_DxO_e8nZAu

@fishy
Copy link
Author

fishy commented Jun 14, 2023

yes but then you need to deal with the comparable check of Signal interface in that Is function (if you just use == blindly you would get unexpected panics)

which kind of goes back to the same question as As in #60756 (comment): is this something we consider worth doing here?

@bcmills
Copy link
Contributor

bcmills commented Jun 14, 2023

You don't need to check whether the Signal implementation is comparable. Every real implementation is.

@fishy
Copy link
Author

fishy commented Jun 14, 2023

That's not really in the "contract" of the API (https://pkg.go.dev/os#Signal definition doesn't guarantee comparability). You are essentially depending on implementation details to not panic.

Although this is very very unlikely to happen in real life, hypothetically someone could define a type implementing os.Signal that wraps some real signals but is uncomparable, and when they try to use it with errors.Is (either as part of the target or trigger the signal into the error) it would cause panic.

@fishy
Copy link
Author

fishy commented Jun 14, 2023

@bcmills do you mind if I leave the errors.Is case out of this proposal for now?

Basically the revised proposal would be to add SignalError to os package as:

// TODO: what's a generic doc comment to describe it here that's appropriate?
type SignalError struct {
  Signal Signal
}

func (se *SignalError) Error() string {
  return "received signal " + se.Signal.String() // I think this error message is generic enough, in signal.NotifyContext I can just use wrap the error again with prefix of "context canceled by "
}

@henvic
Copy link
Contributor

henvic commented Jun 18, 2023

@fishy, I started working on this in parallel too. When I saw you worked on it too, I rebased with your changes.

Please take a look at my changes here:
https://go-review.googlesource.com/c/go/+/504215 https://go-review.googlesource.com/c/go/+/579375

Regarding using a pointer for SignalError, I think we shouldn't.
My reasoning is that, as there isn't something as a hard rule on using pointers for structs, we shouldn't use it for SignalError, as we don't need one here for anything.
Using a pointer in this code (that might have good visibility) when we don't need to might make people think they should do the same regardless, and this can hit them with increased garbage collector pressure when they do so on code on a hot path.

/cc @ianlancetaylor (on the original implementation of NotifyContext you shared some thoughts/ideas on how this type could be named; you might want to take a look).

@fishy
Copy link
Author

fishy commented Jun 20, 2023

@henvic to reiterate #60756 (comment), the problem of allowing non-pointer type to implement error is that when user try to use errors.As it's very error-prone for them to use the wrong type.

@henvic
Copy link
Contributor

henvic commented Apr 16, 2024

I've created a new change request here https://go-review.googlesource.com/c/go/+/579375

@gopherbot
Copy link

Change https://go.dev/cl/579375 mentions this issue: os/signal: add os.SignalError to expose NotifyContext signal

@gopherbot
Copy link

Change https://go.dev/cl/580695 mentions this issue: os/signal: add os.SignalError to expose NotifyContext signal

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Status: Incoming
Development

No branches or pull requests

4 participants