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

SignalError fixes, ContextHandler #25

Merged
merged 3 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: test
on:
pull_request:
types: [opened, synchronize]
push:
branches: [main]
schedule:
- cron: "0 12 1 * *" # first day of the month at 12:00

jobs:
test:
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]

runs-on: ${{ matrix.platform }}

defaults:
run:
shell: bash

steps:
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: 1.20.x

- name: Check out repo
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Prepare cache
id: cache
run: |
echo "GOCACHE=$(go env GOCACHE)" >> $GITHUB_OUTPUT
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
echo "GOVERSION=$(go env GOVERSION)" >> $GITHUB_OUTPUT
mkdir -p $(go env GOCACHE) || true
mkdir -p $(go env GOMODCACHE) || true

- name: Cache
uses: actions/cache@v3
with:
path: |
${{ steps.cache.outputs.GOCACHE }}
${{ steps.cache.outputs.GOMODCACHE }}
key: test.1-${{ runner.os }}-${{ steps.cache.outputs.GOVERSION }}-${{ hashFiles('**/go.mod') }}
restore-keys: |
test.1-${{ runner.os }}-${{ steps.cache.outputs.GOVERSION }}-${{ hashFiles('**/go.mod') }}
test.1-${{ runner.os }}-${{ steps.cache.outputs.GOVERSION }}-
test.1-${{ runner.os }}-

- name: Install tools
run: |
go install honnef.co/go/tools/cmd/staticcheck@latest
go install mvdan.cc/gofumpt@latest
go install github.com/mgechev/revive@latest

- name: Run gofmt
if: matrix.platform != 'windows-latest' # :<
run: diff <(gofmt -d . 2>/dev/null) <(printf '')

- name: Run go vet
run: go vet ./...

- name: Run staticcheck
run: staticcheck ./...

- name: Run gofumpt
run: gofumpt -d -e -l .

- name: Run go test
run: go test -v -race ./...
38 changes: 0 additions & 38 deletions .github/workflows/test.yml

This file was deleted.

74 changes: 65 additions & 9 deletions actors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,41 @@ package run

import (
"context"
"errors"
"fmt"
"os"
"os/signal"
)

// ContextHandler returns an actor, i.e. an execute and interrupt func, that
// terminates when the provided context is canceled.
func ContextHandler(ctx context.Context) (execute func() error, interrupt func(error)) {
ctx, cancel := context.WithCancel(ctx)
return func() error {
<-ctx.Done()
return ctx.Err()
}, func(error) {
cancel()
}
}

// SignalHandler returns an actor, i.e. an execute and interrupt func, that
// terminates with SignalError when the process receives one of the provided
// signals, or the parent context is canceled.
// terminates with ErrSignal when the process receives one of the provided
// signals, or with ctx.Error() when the parent context is canceled. If no
// signals are provided, the actor will terminate on any signal, per
// [signal.Notify].
func SignalHandler(ctx context.Context, signals ...os.Signal) (execute func() error, interrupt func(error)) {
ctx, cancel := context.WithCancel(ctx)
return func() error {
c := make(chan os.Signal, 1)
signal.Notify(c, signals...)
defer signal.Stop(c)
testc := getTestSigChan(ctx)
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, signals...)
defer signal.Stop(sigc)
select {
case sig := <-c:
return SignalError{Signal: sig}
case sig := <-testc:
return &SignalError{Signal: sig}
case sig := <-sigc:
return &SignalError{Signal: sig}
case <-ctx.Done():
return ctx.Err()
}
Expand All @@ -27,13 +45,51 @@ func SignalHandler(ctx context.Context, signals ...os.Signal) (execute func() er
}
}

// SignalError is returned by the signal handler's execute function
// when it terminates due to a received signal.
type testSigChanKey struct{}

func getTestSigChan(ctx context.Context) <-chan os.Signal {
return ctx.Value(testSigChanKey{}).(<-chan os.Signal) // can be nil
}

func putTestSigChan(ctx context.Context, c <-chan os.Signal) context.Context {
return context.WithValue(ctx, testSigChanKey{}, c)
}

// SignalError is returned by the signal handler's execute function when it
// terminates due to a received signal.
//
// SignalError has a design error that impacts comparison with errors.As.
// Callers should prefer using errors.Is(err, ErrSignal) to check for signal
// errors, and should only use errors.As in the rare case that they need to
// program against the specific os.Signal value.
type SignalError struct {
Signal os.Signal
}

// Error implements the error interface.
//
// It was a design error to define this method on a value receiver rather than a
// pointer receiver. For compatibility reasons it won't be changed.
func (e SignalError) Error() string {
return fmt.Sprintf("received signal %s", e.Signal)
}

// Is addresses a design error in the SignalError type, so that errors.Is with
// ErrSignal will return true.
func (e SignalError) Is(err error) bool {
return errors.Is(err, ErrSignal)
}

// As fixes a design error in the SignalError type, so that errors.As with the
// literal `&SignalError{}` will return true.
func (e SignalError) As(target interface{}) bool {
switch target.(type) {
case *SignalError, SignalError:
return true
default:
return false
}
}

// ErrSignal is returned by SignalHandler when a signal triggers termination.
var ErrSignal = errors.New("signal error")
59 changes: 59 additions & 0 deletions actors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package run

import (
"context"
"errors"
"os"
"testing"
"time"
)

func TestContextHandler(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var rg Group
rg.Add(ContextHandler(ctx))
errc := make(chan error, 1)
go func() { errc <- rg.Run() }()
cancel()
select {
case err := <-errc:
if want, have := context.Canceled, err; !errors.Is(have, want) {
t.Errorf("error: want %v, have %v", want, have)
}
case <-time.After(time.Second):
t.Errorf("timeout waiting for error after cancel")
}
}

func TestSignalError(t *testing.T) {
testc := make(chan os.Signal, 1)
ctx := putTestSigChan(context.Background(), testc)

var rg Group
rg.Add(SignalHandler(ctx, os.Interrupt))
testc <- os.Interrupt
err := rg.Run()

var sigerr *SignalError
if want, have := true, errors.As(err, &sigerr); want != have {
t.Errorf("errors.As(err, &sigerr): want %v, have %v", want, have)
}

if sigerr != nil {
if want, have := os.Interrupt, sigerr.Signal; want != have {
t.Errorf("sigerr.Signal: want %v, have %v", want, have)
}
}

if sigerr := &(SignalError{}); !errors.As(err, &sigerr) {
t.Errorf("errors.As(err, <inline sigerr>): failed")
}

if want, have := true, errors.As(err, &(SignalError{})); want != have {
t.Errorf("errors.As(err, &(SignalError{})): want %v, have %v", want, have)
}

if want, have := true, errors.Is(err, ErrSignal); want != have {
t.Errorf("errors.Is(err, ErrSignal): want %v, have %v", want, have)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module github.com/oklog/run

go 1.13
go 1.20