[command] Add a package for common command-line op code
To start with, define a "Cancelable" command which exits
early when the input context emits a Done event.
TEST: go test -count=10000 ./command/...
OUTPUT: ok Fuchsia.googlesource.com/tools/command 13.310s
(Verifying that a test that `sleep`s for 1 millisecond is not flaky)
Change-Id: I6323bc09bc6e90a1fdb818498e8372b16dfe8caa
diff --git a/command/cancelable.go b/command/cancelable.go
new file mode 100644
index 0000000..47e10fe
--- /dev/null
+++ b/command/cancelable.go
@@ -0,0 +1,75 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package command
+
+import (
+ "context"
+ "flag"
+ "log"
+
+ "github.com/google/subcommands"
+)
+
+// Disposer is an object that performs tear down. This is used by Cancelable to gracefully
+// terminate a delegate Command before exiting.
+type Disposer interface {
+ Dispose()
+}
+
+// Cancelable wraps a subcommands.Command so that it is canceled if its input execution
+// context emits a Done event before execution is finished. If the given Command
+// implements the Disposer interface, Dispose is called before the program exits.
+func Cancelable(sub subcommands.Command) subcommands.Command {
+ return &cancelable{sub}
+}
+
+// cancelable wraps a subcommands.Command so that it is canceled if the input execution
+// context emits a Done event before execution is finished. cancelable "masquerades" as
+// the underlying Command. Example Registration:
+//
+// subcommands.Register(command.Cancelable(&OtherSubcommand{}))
+type cancelable struct {
+ sub subcommands.Command
+}
+
+// Name forwards to the underlying Command.
+func (cmd *cancelable) Name() string {
+ return cmd.sub.Name()
+}
+
+// Usage forwards to the underlying Command.
+func (cmd *cancelable) Usage() string {
+ return cmd.sub.Usage()
+}
+
+// Synopsis forwards to the underlying Command.
+func (cmd *cancelable) Synopsis() string {
+ return cmd.sub.Synopsis()
+}
+
+// SetFlags forwards to the underlying Command.
+func (cmd *cancelable) SetFlags(f *flag.FlagSet) {
+ cmd.sub.SetFlags(f)
+}
+
+// Execute runs the underlying Command in a goroutine. If the input context is canceled
+// before execution finishes, execution is canceled and the context's error is logged.
+func (cmd *cancelable) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus {
+ status := make(chan subcommands.ExitStatus)
+ go func() {
+ status <- cmd.sub.Execute(ctx, f, args...)
+ }()
+ select {
+ case <-ctx.Done():
+ if d, ok := cmd.sub.(Disposer); ok {
+ d.Dispose()
+ }
+ log.Println(ctx.Err())
+ return subcommands.ExitFailure
+ case s := <-status:
+ close(status)
+ return s
+ }
+}
diff --git a/command/cancelable_test.go b/command/cancelable_test.go
new file mode 100644
index 0000000..2008d18
--- /dev/null
+++ b/command/cancelable_test.go
@@ -0,0 +1,85 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package command_test
+
+import (
+ "context"
+ "flag"
+ "testing"
+ "time"
+
+ "fuchsia.googlesource.com/tools/command"
+ "github.com/google/subcommands"
+)
+
+func TestCancelableExecute(t *testing.T) {
+ tests := []struct {
+ // The name of this test case
+ name string
+
+ // Whether to cancel the execution context early.
+ cancelContextEarly bool
+
+ // Whether the underlying subcommand is expected to finish
+ expectToFinish bool
+ }{
+ {"when context is canceled early", true, false},
+ {"when context is never canceled", false, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tcmd := &TestCommand{}
+ cmd := command.Cancelable(tcmd)
+ ctx, cancel := context.WithCancel(context.Background())
+ if tt.cancelContextEarly {
+ cancel()
+ cmd.Execute(ctx, flag.NewFlagSet("test", flag.ContinueOnError))
+ } else {
+ cmd.Execute(ctx, flag.NewFlagSet("test", flag.ContinueOnError))
+ cancel()
+ }
+
+ if tcmd.DidFinish && !tt.expectToFinish {
+ t.Errorf("wanted command to exit early but it finished")
+ } else if !tcmd.DidFinish && tt.expectToFinish {
+ t.Errorf("wanted command to finish but it exited early")
+ }
+ })
+ }
+}
+
+// TestCancelableDelegation verifies that Cancelable() returns a subcommand.Command that
+// delegates to the input subcommand.Command.
+func TestCancelableDelegation(t *testing.T) {
+ expectEq := func(t *testing.T, name, expected, actual string) {
+ if expected != actual {
+ t.Errorf("wanted %s to be %q but got %q", name, expected, actual)
+ }
+ }
+ cmd := command.Cancelable(&TestCommand{
+ name: "test_name",
+ usage: "test_usage",
+ synopsis: "test_synopsis",
+ })
+ expectEq(t, "Name", "test_name", cmd.Name())
+ expectEq(t, "Usage", "test_usage", cmd.Usage())
+ expectEq(t, "Synopsis", "test_synopsis", cmd.Synopsis())
+}
+
+type TestCommand struct {
+ name, usage, synopsis string
+ DidFinish bool
+}
+
+func (cmd *TestCommand) Name() string { return cmd.name }
+func (cmd *TestCommand) Usage() string { return cmd.usage }
+func (cmd *TestCommand) Synopsis() string { return cmd.synopsis }
+func (cmd *TestCommand) SetFlags(f *flag.FlagSet) {}
+func (cmd *TestCommand) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus {
+ time.Sleep(time.Millisecond)
+ cmd.DidFinish = true
+ return subcommands.ExitSuccess
+}
diff --git a/command/doc.go b/command/doc.go
new file mode 100644
index 0000000..bc6b9bf
--- /dev/null
+++ b/command/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package command defines common code for writing Fuchsia command line tools.
+package command
diff --git a/command/signals.go b/command/signals.go
new file mode 100644
index 0000000..b889d3b
--- /dev/null
+++ b/command/signals.go
@@ -0,0 +1,25 @@
+package command
+
+import (
+ "context"
+ "os"
+ "os/signal"
+)
+
+// CancelOnSignals returns a Context that emits a Done event when any of the input signals
+// are recieved, assuming those signals can be handled by the current process.
+func CancelOnSignals(ctx context.Context, sigs ...os.Signal) context.Context {
+ ctx, cancel := context.WithCancel(ctx)
+ signals := make(chan os.Signal)
+ signal.Notify(signals, sigs...)
+ go func() {
+ select {
+ case s := <-signals:
+ if s != nil {
+ cancel()
+ close(signals)
+ }
+ }
+ }()
+ return ctx
+}