diff --git a/.circleci/config.yml b/.circleci/config.yml index fb1f708..a2e5690 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,36 +1,48 @@ version: 2.1 orbs: - go: circleci/go@1.7.0 + win: circleci/windows@2.4.1 jobs: test-linux: parameters: go: type: string + docker: + - image: cimg/go:<< parameters.go >> + steps: + - checkout: + path: github.com/vanadium/go.lib + - run: + name: test + command: | + cd github.com/vanadium/go.lib + go test -race --covermode=atomic ./... + + test-windows: executor: - name: go/default - tag: << parameters.go >> + name: win/default steps: - - checkout - - go/load-cache - - go/mod-download - - go/save-cache - - go/test: - covermode: atomic - failfast: true - race: true + - run: git config --global core.autocrlf false + - checkout: + path: github.com/vanadium/go.lib + - run: + name: install mingw + command: | + choco install mingw + - run: + name: test + command: | + cd github.com/vanadium/go.lib + go test -race --covermode=atomic ./... lint: parameters: go: type: string - executor: - name: go/default - tag: << parameters.go >> + docker: + - image: cimg/go:<< parameters.go >> steps: - checkout - - go/load-cache - - go/mod-download - run: name: downloads command: | @@ -42,7 +54,6 @@ jobs: command: | golangci-lint run ./... validjson ./... - - go/save-cache workflows: circleci: @@ -51,7 +62,8 @@ workflows: matrix: parameters: go: ["1.13", "1.17"] + - test-windows - lint: matrix: parameters: - go: ["1.16"] + go: ["1.17"] diff --git a/cmd/flagvar/expandenv_unix.go b/cmd/flagvar/expandenv_unix.go new file mode 100644 index 0000000..2e03633 --- /dev/null +++ b/cmd/flagvar/expandenv_unix.go @@ -0,0 +1,22 @@ +// Copyright 2021 cloudeng llc. All rights reserved. +// Use of this source code is governed by the Apache-2.0 +// license that can be found in the LICENSE file. + +//go:build !windows +// +build !windows + +package flagvar + +import ( + "os" +) + +// ExpandEnv is like os.ExpandEnv but supports 'pseudo' environment +// variables that have OS specific handling as follows: +// +// On Windows $HOME and $PATH are replaced by and $HOMEDRIVE:\\$HOMEPATH +// and $Path respectively. +// On Windows /'s are replaced with \'s. +func ExpandEnv(e string) string { + return os.ExpandEnv(e) +} diff --git a/cmd/flagvar/expandenv_windows.go b/cmd/flagvar/expandenv_windows.go new file mode 100644 index 0000000..d92db2b --- /dev/null +++ b/cmd/flagvar/expandenv_windows.go @@ -0,0 +1,24 @@ +// Copyright 2021 cloudeng llc. All rights reserved. +// Use of this source code is governed by the Apache-2.0 +// license that can be found in the LICENSE file. + +//go:build windows +// +build windows + +package flagvar + +import ( + "os" + "strings" +) + +// ExpandEnv is like os.ExpandEnv but supports 'pseudo' environment +// variables that have OS specific handling as follows: +// +// On Windows $HOME and $PATH are replaced by and $HOMEDRIVE:\\$HOMEPATH +// and $Path respectively. +// On Windows /'s are replaced with \'s. +func ExpandEnv(e string) string { + e = strings.ReplaceAll(e, "$HOME", `$HOMEDRIVE$HOMEPATH`) + return strings.ReplaceAll(os.ExpandEnv(e), `/`, `\`) +} diff --git a/cmd/flagvar/flagvar.go b/cmd/flagvar/flagvar.go index e7a0c88..1b5d2b4 100644 --- a/cmd/flagvar/flagvar.go +++ b/cmd/flagvar/flagvar.go @@ -17,7 +17,6 @@ package flagvar import ( "flag" "fmt" - "os" "reflect" "strconv" "time" @@ -107,7 +106,7 @@ func parseField(t, field string, allowEmpty, expectMore bool) (value, remaining // be supplied. All fields can be quoted (with ') if they need to contain // a comma. // -// Default values may contain shell variables as per os.ExpandEnv. +// Default values may contain shell variables as per flagvar.ExpandEnv. // So $HOME/.configdir may be used for example. func ParseFlagTag(t string) (name, value, usage string, err error) { if len(t) == 0 { @@ -158,7 +157,7 @@ func literalDefault(typeName, literal string, initialValue interface{}) (value i value = defaultLiteralValue(typeName) return } - if tmp := os.ExpandEnv(literal); tmp != literal { + if tmp := ExpandEnv(literal); tmp != literal { usageDefault = literal literal = tmp } diff --git a/cmd/flagvar/flagvar_test.go b/cmd/flagvar/flagvar_test.go index 95dedbb..cea1e28 100644 --- a/cmd/flagvar/flagvar_test.go +++ b/cmd/flagvar/flagvar_test.go @@ -39,7 +39,7 @@ func ExampleRegisterFlagsInStruct() { flagSet.Parse([]string{"--int-flag=42"}) fmt.Println(eg.A) fmt.Println(eg.B) - if got, want := eg.H, filepath.Join(os.Getenv("HOME"), "config"); got != want { + if got, want := eg.H, filepath.Join(flagvar.ExpandEnv("$HOME"), "config"); got != want { fmt.Printf("got %v, want %v", got, want) } // Output: diff --git a/cmdline/cmdline_test.go b/cmdline/cmdline_test.go index 6ac2835..37cb015 100644 --- a/cmdline/cmdline_test.go +++ b/cmdline/cmdline_test.go @@ -20,6 +20,7 @@ import ( "testing" "v.io/x/lib/envvar" + "v.io/x/lib/lookpath" ) var ( @@ -2505,7 +2506,7 @@ func TestExternalSubcommand(t *testing.T) { // Build the external subcommands. for _, subCmd := range []string{"exitcode", "flags", "flat", "foreign", "nested", "repeated"} { - cmd := exec.Command("go", "build", "-o", filepath.Join(tmpDir, "unlikely-"+subCmd), filepath.Join(".", "testdata", subCmd+".go")) + cmd := exec.Command("go", "build", "-o", lookpath.ExecutableFilename(filepath.Join(tmpDir, "unlikely-"+subCmd)), filepath.Join(".", "testdata", subCmd+".go")) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%v, %v", string(out), err) } @@ -2971,6 +2972,7 @@ The global flags are: Stdout: `global1="A B" shared="C D" local="E F" ["x" "y" "z"]` + "\n", }, } + tests = tests[:1] runTestCases(t, cmd, tests) } diff --git a/go.mod b/go.mod index 68e1988..356a373 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,5 @@ require ( github.com/spf13/pflag v1.0.5 golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect ) diff --git a/gosh/cmd.go b/gosh/cmd.go index 6bc0e53..69ba19a 100644 --- a/gosh/cmd.go +++ b/gosh/cmd.go @@ -506,12 +506,11 @@ func isClosedPipeError(err error) bool { if err == io.ErrClosedPipe { return true } - // Closed pipe on os.Pipe; mirrors logic in os/exec/exec_posix.go. - if pe, ok := err.(*os.PathError); ok { - if pe.Op == "write" && pe.Path == "|1" && pe.Err == syscall.EPIPE { - return true - } + + if isSysClosedPipeError(err) { + return true } + // Process exited due to a SIGPIPE signal. if ee, ok := err.(*exec.ExitError); ok { if ws, ok := ee.ProcessState.Sys().(syscall.WaitStatus); ok { @@ -672,7 +671,8 @@ func (c *Cmd) signal(sig os.Signal) error { if !c.isRunning() { return nil } - if err := c.c.Process.Signal(sig); err != nil && err.Error() != errFinished { + if err := c.c.Process.Signal(TranslateSignal(sig)); err != nil && err.Error() != errFinished { + fmt.Printf("cmd signal: %v -> %v: %v\n", sig, TranslateSignal(sig), err) return err } return nil diff --git a/gosh/pipeline_test.go b/gosh/pipeline_test.go index 95dbf4a..992e54a 100644 --- a/gosh/pipeline_test.go +++ b/gosh/pipeline_test.go @@ -184,7 +184,7 @@ func TestPipelineSignal(t *testing.T) { time.Sleep(100 * time.Millisecond) p.Signal(s) switch { - case s == os.Interrupt: + case gosh.TranslateSignal(s) == os.Interrupt: // Wait should succeed as long as the exit code was 0, regardless of // whether the signal arrived or the processes had already exited. p.Wait() diff --git a/gosh/shell.go b/gosh/shell.go index 75c79d0..74d1f84 100644 --- a/gosh/shell.go +++ b/gosh/shell.go @@ -598,6 +598,7 @@ func buildGoPkg(sh *Shell, binDir, pkg string, flags ...string) (string, error) default: binPath = filepath.Join(binDir, outputFlag) } + binPath = ExecutableFilename(binPath) // If the binary already exists at the target location, don't rebuild it. if _, err := os.Stat(binPath); err == nil { return binPath, nil diff --git a/gosh/shell_test.go b/gosh/shell_test.go index 5d566f7..3044ac4 100644 --- a/gosh/shell_test.go +++ b/gosh/shell_test.go @@ -26,12 +26,12 @@ import ( "runtime/debug" "strconv" "strings" - "syscall" "testing" "time" "v.io/x/lib/gosh" lib "v.io/x/lib/gosh/internal/gosh_example_lib" + "v.io/x/lib/lookpath" ) var errFake = errors.New("fake error") @@ -506,7 +506,7 @@ func TestLookPath(t *testing.T) { defer sh.Cleanup() binDir := sh.MakeTempDir() - sh.Vars["PATH"] = binDir + ":" + sh.Vars["PATH"] + sh.Vars["PATH"] = binDir + string(filepath.ListSeparator) + sh.Vars[lookpath.PathEnvVar] relName := "hw" absName := filepath.Join(binDir, relName) gosh.BuildGoPkg(sh, "", helloWorldPkg, "-o", absName) @@ -863,7 +863,7 @@ func TestSignal(t *testing.T) { time.Sleep(100 * time.Millisecond) c.Signal(s) switch { - case s == os.Interrupt: + case gosh.TranslateSignal(s) == os.Interrupt: // Wait should succeed as long as the exit code was 0, regardless of // whether the signal arrived or the process had already exited. c.Wait() @@ -892,25 +892,6 @@ var processGroup = gosh.RegisterFunc("processGroup", func(n int) { time.Sleep(time.Minute) }) -func TestCleanupProcessGroup(t *testing.T) { - sh := gosh.NewShell(t) - defer sh.Cleanup() - - c := sh.FuncCmd(processGroup, 5) - c.Start() - pids := c.AwaitVars("pids")["pids"] - c.Signal(os.Interrupt) - - // Wait for all processes in the child's process group to exit. - for syscall.Kill(-c.Pid(), 0) != syscall.ESRCH { - time.Sleep(100 * time.Millisecond) - } - for _, pid := range strings.Split(pids, ",") { - p, _ := strconv.Atoi(pid) - eq(t, syscall.Kill(p, 0), syscall.ESRCH) - } -} - func TestTerminate(t *testing.T) { sh := gosh.NewShell(t) defer sh.Cleanup() @@ -1104,21 +1085,21 @@ func TestBuildGoPkg(t *testing.T) { // Set -o to an absolute name. relName := "hw" absName := filepath.Join(sh.MakeTempDir(), relName) - eq(t, gosh.BuildGoPkg(sh, "", helloWorldPkg, "-o", absName), absName) + eq(t, gosh.BuildGoPkg(sh, "", helloWorldPkg, "-o", absName), gosh.ExecutableFilename(absName)) c := sh.Cmd(absName) eq(t, c.Stdout(), helloWorldStr) // Set -o to a relative name with no path separators. binDir := sh.MakeTempDir() absName = filepath.Join(binDir, relName) - eq(t, gosh.BuildGoPkg(sh, binDir, helloWorldPkg, "-o", relName), absName) + eq(t, gosh.BuildGoPkg(sh, binDir, helloWorldPkg, "-o", relName), gosh.ExecutableFilename(absName)) c = sh.Cmd(absName) eq(t, c.Stdout(), helloWorldStr) // Set -o to a relative name that contains a path separator. relNameWithSlash := filepath.Join("subdir", relName) absName = filepath.Join(binDir, relNameWithSlash) - eq(t, gosh.BuildGoPkg(sh, binDir, helloWorldPkg, "-o", relNameWithSlash), absName) + eq(t, gosh.BuildGoPkg(sh, binDir, helloWorldPkg, "-o", relNameWithSlash), gosh.ExecutableFilename(absName)) c = sh.Cmd(absName) eq(t, c.Stdout(), helloWorldStr) @@ -1133,7 +1114,7 @@ func TestBuildGoPkg(t *testing.T) { // Use --o instead of -o. absName = filepath.Join(sh.MakeTempDir(), relName) - gosh.BuildGoPkg(sh, "", helloWorldPkg, "--o", absName) + gosh.BuildGoPkg(sh, "", helloWorldPkg, "--o", gosh.ExecutableFilename(absName)) c = sh.Cmd(absName) eq(t, c.Stdout(), helloWorldStr) } diff --git a/gosh/shell_unix_test.go b/gosh/shell_unix_test.go new file mode 100644 index 0000000..3190a6c --- /dev/null +++ b/gosh/shell_unix_test.go @@ -0,0 +1,38 @@ +// Copyright 2015 The Vanadium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !windows +// +build !windows + +package gosh_test + +import ( + "os" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "v.io/x/lib/gosh" +) + +func TestCleanupProcessGroup(t *testing.T) { + sh := gosh.NewShell(t) + defer sh.Cleanup() + + c := sh.FuncCmd(processGroup, 5) + c.Start() + pids := c.AwaitVars("pids")["pids"] + c.Signal(os.Interrupt) + + // Wait for all processes in the child's process group to exit. + for syscall.Kill(-c.Pid(), 0) != syscall.ESRCH { + time.Sleep(100 * time.Millisecond) + } + for _, pid := range strings.Split(pids, ",") { + p, _ := strconv.Atoi(pid) + eq(t, syscall.Kill(p, 0), syscall.ESRCH) + } +} diff --git a/gosh/unix.go b/gosh/unix.go index 3595518..2873f23 100644 --- a/gosh/unix.go +++ b/gosh/unix.go @@ -8,6 +8,7 @@ package gosh import ( + "os" "syscall" "time" ) @@ -100,3 +101,21 @@ func (c *Cmd) cleanupProcessGroup() { } syscall.Kill(-c.Pid(), syscall.SIGKILL) } + +func isSysClosedPipeError(err error) bool { + // Closed pipe on os.Pipe; mirrors logic in os/exec/exec_posix.go. + if pe, ok := err.(*os.PathError); ok { + return pe.Op == "write" && + pe.Path == "|1" && + pe.Err == syscall.EPIPE + } + return false +} + +func TranslateSignal(sig os.Signal) os.Signal { + return sig +} + +func ExecutableFilename(n string) string { + return n +} diff --git a/gosh/unix_test.go b/gosh/unix_test.go new file mode 100644 index 0000000..44ec05f --- /dev/null +++ b/gosh/unix_test.go @@ -0,0 +1,5 @@ +// Copyright 2021 The Vanadium 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 gosh diff --git a/gosh/windows.go b/gosh/windows.go index 6f75980..1032e6c 100644 --- a/gosh/windows.go +++ b/gosh/windows.go @@ -6,6 +6,12 @@ package gosh +import ( + "os" + "strings" + "syscall" +) + // TODO(sadovsky): Maybe wrap every child process with a "supervisor" process // that calls InitChildMain. @@ -77,4 +83,23 @@ func (c *Cmd) cleanupProcessGroup() { // No grace period. c.c.Process.Kill() -} \ No newline at end of file +} + +func isSysClosedPipeError(err error) bool { + // Closed pipe on os.Pipe; mirrors logic in os/exec/exec_posix.go. + const _ERROR_NO_DATA = syscall.Errno(0xe8) + if pe, ok := err.(*os.PathError); ok { + return pe.Op == "write" && + pe.Path == "|1" && + (pe.Err == syscall.ERROR_BROKEN_PIPE || pe.Err == _ERROR_NO_DATA) + } + return false +} + +func TranslateSignal(sig os.Signal) os.Signal { + return os.Kill +} + +func ExecutableFilename(n string) string { + return strings.TrimSuffix(n, ".exe") + ".exe" +} diff --git a/gosh/windows_test.go b/gosh/windows_test.go new file mode 100644 index 0000000..44ec05f --- /dev/null +++ b/gosh/windows_test.go @@ -0,0 +1,5 @@ +// Copyright 2021 The Vanadium 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 gosh diff --git a/lookpath/exe_unix.go b/lookpath/exe_unix.go new file mode 100644 index 0000000..5e4ef83 --- /dev/null +++ b/lookpath/exe_unix.go @@ -0,0 +1,58 @@ +// Copyright 2021 The Vanadium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !windows +// +build !windows + +package lookpath + +import ( + "os" + "path/filepath" + "strings" +) + +func isExecutablePath(dir, base string) (string, bool) { + file, err := filepath.Abs(filepath.Join(dir, base)) + if err != nil { + return "", false + } + info, err := os.Stat(file) + if err != nil { + return "", false + } + if !isExecutable(info) { + return "", false + } + return file, true +} + +func isExecutable(info os.FileInfo) bool { + mode := info.Mode() + return !mode.IsDir() && mode&0111 != 0 +} + +// PathEnvVar is the system specific environment variable name for command +// paths; commonly PATH on UNIX systems. +const PathEnvVar = "PATH" + +// ExecutableFilename returns a system specific filename for executable +// files. On UNIX systems the filename is unchanged. +func ExecutableFilename(name string) string { + return name +} + +// ExecutableBasename returns the system specific basename (i.e. without +// any executable suffix) for executable files. +// On UNIX systems the filename is unchanged. +func ExecutableBasename(name string) string { + return strings.TrimSuffix(name, ".exe") +} + +// translateEnv translates commonly used environment variables to their +// system specific equivalents, e.g. the commonly used PATH on UNIX +// systems to Path on Windows. +func translateEnv(env map[string]string) map[string]string { + return env +} diff --git a/lookpath/exe_windows.go b/lookpath/exe_windows.go new file mode 100644 index 0000000..d072f5a --- /dev/null +++ b/lookpath/exe_windows.go @@ -0,0 +1,63 @@ +// Copyright 2021 The Vanadium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows +// +build windows + +package lookpath + +import ( + "os" + "path/filepath" + "strings" +) + +func isExecutablePath(dir, base string) (string, bool) { + if strings.HasSuffix(base, ".exe") { + file, err := filepath.Abs(filepath.Join(dir, base)) + return file, err == nil + } + file, err := filepath.Abs(filepath.Join(dir, base+".exe")) + if err != nil { + return "", false + } + info, err := os.Stat(file) + return file, err == nil && !info.Mode().IsDir() +} + +func isExecutable(info os.FileInfo) bool { + return strings.HasSuffix(info.Name(), ".exe") +} + +// PathEnvVar is the system specific environment variable name for command +// paths; Path on Windows systems. +const PathEnvVar = "Path" + +// ExecutableFilename returns a system specific filename for executable +// files. On Windows a '.exe' suffix is appended. +func ExecutableFilename(name string) string { + return strings.TrimSuffix(name, ".exe") + ".exe" +} + +// ExecutableBasename returns the system specific basename (i.e. without +// any executable suffix) for executable files. +// On Windows a '.exe' suffix is remove. +func ExecutableBasename(name string) string { + return strings.TrimSuffix(name, ".exe") +} + +// translateEnv translates commonly used environment variables to their +// system specific equivalents, e.g. the commonly used PATH on UNIX +// systems to Path on Windows. +func translateEnv(env map[string]string) map[string]string { + if p, ok := env["PATH"]; ok { + nenv := make(map[string]string, len(env)) + for k, v := range env { + nenv[k] = v + } + nenv[PathEnvVar] = p + return nenv + } + return env +} diff --git a/lookpath/lookpath.go b/lookpath/lookpath.go index 0a448a5..dad1b4c 100644 --- a/lookpath/lookpath.go +++ b/lookpath/lookpath.go @@ -5,11 +5,8 @@ // Package lookpath implements utilities to find executables. package lookpath -// TODO(toddw): implement for non-unix systems. - import ( "io/ioutil" - "os" "os/exec" "path/filepath" "sort" @@ -18,7 +15,7 @@ import ( func splitPath(env map[string]string) []string { var dirs []string - for _, dir := range strings.Split(env["PATH"], string(filepath.ListSeparator)) { + for _, dir := range strings.Split(env[PathEnvVar], string(filepath.ListSeparator)) { if dir != "" { dirs = append(dirs, dir) } @@ -26,19 +23,19 @@ func splitPath(env map[string]string) []string { return dirs } -func isExecutable(info os.FileInfo) bool { - mode := info.Mode() - return !mode.IsDir() && mode&0111 != 0 -} - // Look returns the absolute path of the executable with the given name. If -// name only contains a single path component, the dirs in env["PATH"] are -// consulted, and the first match is returned. Otherwise, for multi-component -// paths, the absolute path of the name is looked up directly. +// name only contains a single path component, the dirs in env["PATH"] +// or env["Path"] on windows (use lookpath.PathEnvVar to obtain the os specific +// value) are consulted, and the first match is returned. Otherwise, for +// multi-component paths, the absolute path of the name is looked up directly. // // The behavior is the same as LookPath in the os/exec package, but allows the // env to be passed in explicitly. +// On Windows systems PATH is copied to Path in env unless Path is already +// defined. Again, on Windows, the returned executable name does not include +// the .exe suffix. func Look(env map[string]string, name string) (string, error) { + env = translateEnv(env) var dirs []string base := filepath.Base(name) if base == name { @@ -47,18 +44,9 @@ func Look(env map[string]string, name string) (string, error) { dirs = []string{filepath.Dir(name)} } for _, dir := range dirs { - file, err := filepath.Abs(filepath.Join(dir, base)) - if err != nil { - continue - } - info, err := os.Stat(file) - if err != nil { - continue - } - if !isExecutable(info) { - continue + if file, ok := isExecutablePath(dir, base); ok { + return ExecutableBasename(file), nil } - return file, nil } return "", &exec.Error{Name: name, Err: exec.ErrNotFound} } @@ -74,6 +62,7 @@ func Look(env map[string]string, name string) (string, error) { // property. As a consequence, you may pass in a pre-populated names map to // prevent matching those names. It is fine to pass in a nil names map. func LookPrefix(env map[string]string, prefix string, names map[string]bool) ([]string, error) { + env = translateEnv(env) if names == nil { names = make(map[string]bool) } @@ -98,16 +87,16 @@ func LookPrefix(env map[string]string, prefix string, names map[string]bool) ([] continue } name := info.Name() - file := filepath.Join(dir, name) - index := strings.LastIndex(file, prefix) - if index == -1 || strings.ContainsRune(file[index+len(prefix):], filepath.Separator) { + bprefix := filepath.Base(prefix) + if !strings.HasPrefix(name, bprefix) { continue } + name = ExecutableBasename(name) if names[name] { continue } names[name] = true - all = append(all, file) + all = append(all, filepath.Join(dir, name)) } } if len(all) > 0 { diff --git a/lookpath/lookpath_test.go b/lookpath/lookpath_test.go index 614c2aa..69e84da 100644 --- a/lookpath/lookpath_test.go +++ b/lookpath/lookpath_test.go @@ -26,6 +26,7 @@ func mkdir(t *testing.T, d ...string) string { } func mkfile(t *testing.T, dir, file string, perm os.FileMode) string { + file = lookpath.ExecutableFileNameForTests(file, perm) path := filepath.Join(dir, file) f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm) if err != nil { @@ -34,7 +35,7 @@ func mkfile(t *testing.T, dir, file string, perm os.FileMode) string { if err := f.Close(); err != nil { t.Fatal(err) } - return path + return lookpath.ExecutableBasename(path) } func initTmpDir(t *testing.T) (string, func()) { @@ -93,17 +94,21 @@ func TestLook(t *testing.T) { {nil, aExe, ""}, {nil, bExe, bExe}, } - for _, test := range tests { + for i, test := range tests { hdr := fmt.Sprintf("env=%v name=%v", test.Env, test.Name) look, err := lookpath.Look(test.Env, test.Name) if got, want := look, test.Want; got != want { - t.Errorf("%s got %v, want %v", hdr, got, want) + t.Errorf("%v: %s got %v, want %v", i, hdr, got, want) + return + } + if strings.HasSuffix(look, ".exe") { + t.Errorf("executables should never have a .exe suffix, even on Windows") } if (look == "") == (err == nil) { - t.Errorf("%s got mismatched look=%v err=%v", hdr, look, err) + t.Errorf("%v: %s got mismatched look=%v err=%v", i, hdr, look, err) } if err != nil && !isNotFoundError(err, test.Name) { - t.Errorf("%s got wrong error %v", hdr, err) + t.Errorf("%v: %s got wrong error %v", i, hdr, err) } } } @@ -163,17 +168,22 @@ func TestLookPrefix(t *testing.T) { {nil, bExe, nil, []string{bExe}}, {nil, filepath.Join(dirB, "e"), nil, []string{bExe}}, } - for _, test := range tests { + for i, test := range tests { hdr := fmt.Sprintf("env=%v prefix=%v names=%v", test.Env, test.Prefix, test.Names) look, err := lookpath.LookPrefix(test.Env, test.Prefix, test.Names) if got, want := look, test.Want; !reflect.DeepEqual(got, want) { - t.Errorf("%s got %v, want %v", hdr, got, want) + t.Errorf("%v: %s got %v, want %v", i, hdr, got, want) } if (look == nil) == (err == nil) { - t.Errorf("%s got mismatched look=%v err=%v", hdr, look, err) + t.Errorf("%v: %s got mismatched look=%v err=%v", i, hdr, look, err) + } + for _, l := range look { + if strings.HasSuffix(l, ".exe") { + t.Errorf("executables should never have a .exe suffix, even on Windows") + } } if err != nil && !isNotFoundError(err, test.Prefix+"*") { - t.Errorf("%s got wrong error %v", hdr, err) + t.Errorf("%v: %s got wrong error %v", i, hdr, err) } } } diff --git a/lookpath/unix_test.go b/lookpath/unix_test.go new file mode 100644 index 0000000..4092c30 --- /dev/null +++ b/lookpath/unix_test.go @@ -0,0 +1,16 @@ +// Copyright 2021 The Vanadium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !windows +// +build !windows + +package lookpath + +import ( + "os" +) + +func ExecutableFileNameForTests(filename string, perm os.FileMode) string { + return filename +} diff --git a/lookpath/windows_test.go b/lookpath/windows_test.go new file mode 100644 index 0000000..0f2b8df --- /dev/null +++ b/lookpath/windows_test.go @@ -0,0 +1,19 @@ +// Copyright 2021 The Vanadium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows +// +build windows + +package lookpath + +import ( + "os" +) + +func ExecutableFileNameForTests(filename string, perm os.FileMode) string { + if perm&0111 != 0 { + return filename + ".exe" + } + return filename +} diff --git a/netconfig/osnetconfig/common.go b/netconfig/osnetconfig/common.go index a73b69d..8e7e66f 100644 --- a/netconfig/osnetconfig/common.go +++ b/netconfig/osnetconfig/common.go @@ -3,15 +3,9 @@ // license that can be found in the LICENSE file. // Package osnetconfig provides OS specific routines for detecting network -// changes and reading the route table; it uses cgo to to do so. Unfortunately -// some applications prefer to avoid the use of cgo entirely and this leads to -// a convoluted +// changes and reading the route table; it uses cgo to to do so on some systems. package osnetconfig -// Force this file to compile as cgo, to work around bazel/rules_go -// limitations. See also https://github.com/bazelbuild/rules_go/issues/255 - -import "C" import ( "net" "sync" @@ -76,6 +70,12 @@ func (n *Notifier) Shutdown() { } } +func (n *Notifier) stopped() bool { + n.Lock() + defer n.Unlock() + return n.stop +} + // ding returns true when the nofitifer is being shutdown. func (n *Notifier) ding() bool { // Changing networks usually spans many seconds and involves diff --git a/netconfig/osnetconfig/ipaux_bsd.go b/netconfig/osnetconfig/ipaux_bsd.go index 0d607ee..1a63d08 100644 --- a/netconfig/osnetconfig/ipaux_bsd.go +++ b/netconfig/osnetconfig/ipaux_bsd.go @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build darwin || dragonfly || freebsd || netbsd || openbsd // +build darwin dragonfly freebsd netbsd openbsd package osnetconfig diff --git a/netconfig/osnetconfig/ipaux_linux.go b/netconfig/osnetconfig/ipaux_linux.go index f6b5277..0ffcfad 100644 --- a/netconfig/osnetconfig/ipaux_linux.go +++ b/netconfig/osnetconfig/ipaux_linux.go @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build linux // +build linux package osnetconfig diff --git a/netconfig/osnetconfig/ipaux_other.go b/netconfig/osnetconfig/ipaux_other.go index 1020c61..d83720b 100644 --- a/netconfig/osnetconfig/ipaux_other.go +++ b/netconfig/osnetconfig/ipaux_other.go @@ -2,16 +2,11 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -// +build !linux,!darwin,!dragonfly,!freebsd,!netbsd,!openbsd -// TODO(bprosnitz) Should change for nacl? +//go:build !linux && !darwin && !dragonfly && !freebsd && !netbsd && !openbsd && !windows +// +build !linux,!darwin,!dragonfly,!freebsd,!netbsd,!openbsd,!windows package osnetconfig -// Force this file to compile as cgo, to work around bazel/rules_go -// limitations. See also https://github.com/bazelbuild/rules_go/issues/255 - -import "C" - // Code to signal a network change every 2 minutes. We use // this for systems where we don't yet have a good way to // watch for network changes. diff --git a/netconfig/osnetconfig/ipaux_windows.go b/netconfig/osnetconfig/ipaux_windows.go new file mode 100644 index 0000000..7b2ee30 --- /dev/null +++ b/netconfig/osnetconfig/ipaux_windows.go @@ -0,0 +1,344 @@ +// Copyright 2015 The Vanadium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows +// +build windows + +package osnetconfig + +import ( + "bufio" + "bytes" + "fmt" + "net" + "os/exec" + "strconv" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + "v.io/x/lib/netconfig/route" + "v.io/x/lib/vlog" +) + +var ( + modws2_32 = windows.NewLazySystemDLL("ws2_32.dll") + modiphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") + procNotifyRouteChange *windows.LazyProc + overlap = &windows.Overlapped{} +) + +func init() { + modws2_32.System, modiphlpapi.System = true, true + procNotifyRouteChange = modiphlpapi.NewProc("NotifyRouteChange") +} + +func (n *Notifier) initLocked() error { + var err error + overlap.HEvent, err = windows.CreateEvent(nil, 0, 0, nil) + if err != nil { + return err + } + go func() { + n.watcher(overlap, 1000*5) + windows.Close(overlap.HEvent) + }() + return nil +} + +func waitForRouteTableChange(overlap *windows.Overlapped, delayMillisecond uint32) bool { + notifyHandle := windows.Handle(0) + syscall.Syscall(uintptr(procNotifyRouteChange.Addr()), 2, uintptr(notifyHandle), uintptr(unsafe.Pointer(overlap)), 0) + event, err := windows.WaitForSingleObject(overlap.HEvent, delayMillisecond) + return err == nil && event == windows.WAIT_OBJECT_0 +} + +func (n *Notifier) watcher(overlap *windows.Overlapped, delayMillisecond uint32) { + for { + if waitForRouteTableChange(overlap, delayMillisecond) { + if n.ding() { + return + } + } + if n.stopped() { + return + } + } +} + +// GetIPRoutes implements netconfig.Notifier. +func (n *Notifier) GetIPRoutes(defaultOnly bool) []route.IPRoute { + cmd := exec.Command("route", "print") + out, err := cmd.CombinedOutput() + if err != nil { + vlog.Infof("%s failed: %s: %v\n", strings.Join(cmd.Args, " "), out, err) + return nil + } + ifcs, err := net.Interfaces() + if err != nil { + vlog.Infof("failed to obtain network interface configuration: %v", err) + return nil + } + nifcs, err := newNetIfcs(ifcs) + if err != nil { + vlog.Infof("failed to parse network interface configuration: %v", err) + return nil + } + routes, err := parseWindowsRouteCommandOutput(nifcs, string(out), defaultOnly) + if err != nil { + vlog.Infof("%s failed to parse output: %s: %v\n", strings.Join(cmd.Args, " "), out, err) + return nil + } + return routes +} + +// parseWindowsRouteCommandOutput parses the output of the windows +// 'route print' command's output and is used by GetIPRoutes. +func parseWindowsRouteCommandOutput(nifcs netIfcs, output string, defaultOnly bool) ([]route.IPRoute, error) { + lines, err := readLines(output) + if err != nil { + return nil, err + } + routes, err := nifcs.parseIPv4(lines, defaultOnly) + if err != nil { + return nil, err + } + v6, err := nifcs.parseIPv6(lines, defaultOnly) + if err != nil { + return nil, err + } + return append(routes, v6...), nil +} + +func readLines(output string) ([]string, error) { + sc := bufio.NewScanner(bytes.NewBufferString(output)) + lines := []string{} + for sc.Scan() { + lines = append(lines, sc.Text()) + } + return lines, sc.Err() +} + +func scanTo(lines []string, text string) int { + for i, l := range lines { + if strings.HasPrefix(l, text) { + return i + } + } + return -1 +} + +type netIfc struct { + idx int + addrs []net.IP +} + +type netIfcs []netIfc + +func newNetIfcs(ifcs []net.Interface) (netIfcs, error) { + nifcs := make([]netIfc, len(ifcs)) + for i, ifc := range ifcs { + nifcs[i].idx = ifc.Index + addrs, err := ifc.Addrs() + if err != nil { + return nil, err + } + nifcs[i].addrs = make([]net.IP, 0, len(addrs)) + for _, a := range addrs { + if ipn, ok := a.(*net.IPNet); ok { + nifcs[i].addrs = append(nifcs[i].addrs, ipn.IP) + } + } + } + return nifcs, nil +} + +func (ni netIfcs) ifcIndexForAddr(addr net.IP) int { + for _, ifc := range ni { + for _, a := range ifc.addrs { + if bytes.Equal(a, addr) { + return ifc.idx + } + } + } + return -1 +} + +func (ni netIfcs) ipv6AddrForIndex(idx int) []net.IP { + for _, ifc := range ni { + if ifc.idx != idx { + continue + } + var v6addrs []net.IP + for _, a := range ifc.addrs { + if len(a) == net.IPv6len { + v6addrs = append(v6addrs, a) + } + } + return v6addrs + } + return nil +} + +func findRoutingTable(lines []string, title string) (int, int, error) { + table := scanTo(lines, title) + if table == -1 { + return -1, -1, fmt.Errorf("no %v found", title) + } + start := scanTo(lines[table:], "Active Routes:") + if start == -1 { + return -1, -1, fmt.Errorf("no %v found", title) + } + if len(lines) < start+2 { + return -1, -1, fmt.Errorf("no entries found for %v", title) + } + start += 2 // skip past header 'Network Destination..... + stop := scanTo(lines[table+start:], "================================") + if stop == -1 { + return -1, -1, fmt.Errorf("failed to find end of %v", title) + } + return table + start, table + start + stop, nil +} + +func (ni netIfcs) parseIPv4(lines []string, defaultOnly bool) ([]route.IPRoute, error) { + start, stop, err := findRoutingTable(lines, "IPv4 Route Table") + if err != nil { + return nil, err + } + return ni.parseIP4Routes(lines[start:stop], defaultOnly) +} + +func (ni netIfcs) parseIP4Routes(lines []string, defaultOnly bool) ([]route.IPRoute, error) { + const ( + netdst = 0 + netmask = 1 + gateway = 2 + ifc = 3 + ) + var routes []route.IPRoute + for _, l := range lines { + parts := strings.Fields(l) + if got, want := len(parts), 5; got != want { + return nil, fmt.Errorf("IP4 route has incorrect # of fields: got %v, want %v, from %v", got, want, l) + } + dstIP := net.ParseIP(parts[netdst]) + if dstIP == nil { + return nil, fmt.Errorf("invalid destination address: %v", parts[netdst]) + } + mask := net.ParseIP(parts[netmask]) + if dstIP == nil { + return nil, fmt.Errorf("invalid netmask: %v", parts[netmask]) + } + ifcIP := net.ParseIP(parts[ifc]) + if dstIP == nil { + return nil, fmt.Errorf("invalid interface address: %v", parts[ifc]) + } + var gw net.IP + if gws := parts[gateway]; gws == "On-link" { + gw = ifcIP + } else { + gw = net.ParseIP(gws) + if gw == nil { + return nil, fmt.Errorf("invalid gateway: %v", gw) + } + } + idx := ni.ifcIndexForAddr(ifcIP) + if idx < 0 { + return nil, fmt.Errorf("failed to determine interface index for route %v", l) + } + r := route.IPRoute{ + Net: net.IPNet{ + IP: dstIP, + Mask: net.IPMask(mask.To4()), + }, + Gateway: gw, + IfcIndex: idx, + } + if defaultOnly && !route.IsDefaultIPRoute(&r) { + continue + } + routes = append(routes, r) + } + return routes, nil +} + +func (ni netIfcs) parseIPv6(lines []string, defaultOnly bool) ([]route.IPRoute, error) { + start, stop, err := findRoutingTable(lines, "IPv6 Route Table") + if err != nil { + return nil, err + } + return ni.parseIP6Routes(lines[start:stop], defaultOnly) +} + +func (ni netIfcs) parseIP6Routes(lines []string, defaultOnly bool) ([]route.IPRoute, error) { + const ( + ifc = 0 + netdst = 2 + gateway = 3 + ) + var routes []route.IPRoute + + // Annoyingly, for long ipv6 addresses the gateway column may printed + // on the following line: + // 3 281 fe80::4986:e542:6726:73ed/128 + // On-link + merged := make([][]string, 0, len(lines)) + nl := len(lines) + for i := 0; i < nl; i++ { + fields := strings.Fields(lines[i]) + nf := len(fields) + if nf < 3 || nf > 4 { + return nil, fmt.Errorf("IP6 route has incorrect # of fields: got %v, want 3 or 4, from %v", nf, lines[i]) + } + if len(fields) == 4 { + merged = append(merged, fields) + continue + } + if i+1 >= nl { + return nil, fmt.Errorf("IP6 route has is missing the gateway field on a subsrquent line: %v", lines[i]) + } + nfields := strings.Fields(lines[i+1]) + if len(nfields) != 1 { + return nil, fmt.Errorf("IP6 route has more fields than the gateway on a following line: %v", lines[i+1]) + } + merged = append(merged, append(fields, nfields[0])) + i++ // skip next line. + } + + for _, parts := range merged { + _, dstIP, err := net.ParseCIDR(parts[netdst]) + if err != nil { + return nil, fmt.Errorf("invalid destination address: %v: %v", parts[netdst], err) + } + ifcIdx, err := strconv.Atoi(parts[ifc]) + if err != nil { + return nil, fmt.Errorf("failed to parse interface index: %v: %v", parts[ifc], err) + } + + var gw net.IP + if gws := parts[gateway]; gws == "On-link" { + addrs := ni.ipv6AddrForIndex(ifcIdx) + if len(addrs) == 0 { + return nil, fmt.Errorf("no addresses found for interface %v", ifcIdx) + } + gw = addrs[0] + } else { + gw = net.ParseIP(gws) + if gw == nil { + return nil, fmt.Errorf("invalid gateway: %v", gw) + } + } + r := route.IPRoute{ + Net: *dstIP, + Gateway: gw, + IfcIndex: ifcIdx, + } + if defaultOnly && !route.IsDefaultIPRoute(&r) { + continue + } + routes = append(routes, r) + } + return routes, nil +} diff --git a/netconfig/osnetconfig/windows_test.go b/netconfig/osnetconfig/windows_test.go new file mode 100644 index 0000000..8d4e32d --- /dev/null +++ b/netconfig/osnetconfig/windows_test.go @@ -0,0 +1,185 @@ +// Copyright 2021 The Vanadium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows +// +build windows + +package osnetconfig + +import ( + "net" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "v.io/x/lib/netconfig/route" +) + +const testData = `=========================================================================== +Interface List + 3...00 1c 42 74 78 6d ......Intel(R) 82574L Gigabit Network Connection + 1...........................Software Loopback Interface 1 +=========================================================================== + +IPv4 Route Table +=========================================================================== +Active Routes: +Network Destination Netmask Gateway Interface Metric + 0.0.0.0 0.0.0.0 172.16.1.1 172.16.1.235 25 + 127.0.0.0 255.0.0.0 On-link 127.0.0.1 331 + 127.0.0.1 255.255.255.255 On-link 127.0.0.1 331 + 127.255.255.255 255.255.255.255 On-link 127.0.0.1 331 + 172.16.1.0 255.255.255.0 On-link 172.16.1.235 281 + 172.16.1.235 255.255.255.255 On-link 172.16.1.235 281 + 172.16.1.255 255.255.255.255 On-link 172.16.1.235 281 + 224.0.0.0 240.0.0.0 On-link 127.0.0.1 331 + 224.0.0.0 240.0.0.0 On-link 172.16.1.235 281 + 255.255.255.255 255.255.255.255 On-link 127.0.0.1 331 + 255.255.255.255 255.255.255.255 On-link 172.16.1.235 281 +=========================================================================== +Persistent Routes: + None + +IPv6 Route Table +=========================================================================== +Active Routes: + If Metric Network Destination Gateway + 1 331 ::1/128 On-link + 3 281 fe80::/64 On-link + 3 281 fe80::4986:e542:6726:73ed/128 + On-link + 1 331 ff00::/8 On-link + 3 281 ff00::/8 On-link +=========================================================================== +` + +func TestGetRoutes(t *testing.T) { + notifier := NewNotifier(time.Second) + routes := notifier.GetIPRoutes(false) + if routes == nil { + t.Errorf("failed to get any system routes") + } + if len(routes) < 2 { + t.Errorf("too few routes") + } +} + +func fakeInterfaceInfo() netIfcs { + a31, _, _ := net.ParseCIDR("fe80::4986:e542:6726:73ed/64") + a32, _, _ := net.ParseCIDR("172.16.1.235/24") + a11, _, _ := net.ParseCIDR("::1/128") + a12, _, _ := net.ParseCIDR("127.0.0.1/8") + return []netIfc{ + {3, []net.IP{a31, a32}}, + {1, []net.IP{a11, a12}}, + } +} + +func TestRouteParsing(t *testing.T) { + nifcs := fakeInterfaceInfo() + routes, err := parseWindowsRouteCommandOutput(nifcs, testData, false) + if err != nil { + t.Errorf("failed to parse route command's output: %v", err) + } + if got, want := len(routes), 16; got != want { + t.Fatalf("got %v, want %v", got, want) + } + defaultRoute := route.IPRoute{ + Net: net.IPNet{ + IP: net.ParseIP("0.0.0.0"), + Mask: net.CIDRMask(0, 32), + }, + Gateway: net.ParseIP("172.16.1.1"), + IfcIndex: 3, + } + if got, want := routes[0], defaultRoute; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + ip, ipn, _ := net.ParseCIDR("fe80::4986:e542:6726:73ed/128") + rt := route.IPRoute{ + Net: *ipn, + Gateway: ip, + IfcIndex: 3, + } + if got, want := routes[13], rt; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + + routes, err = parseWindowsRouteCommandOutput(nifcs, testData, true) + if err != nil { + t.Errorf("failed to parse route command's output: %v", err) + } + if got, want := len(routes), 1; got != want { + t.Fatalf("got %v, want %v", got, want) + } + if got, want := routes[0], defaultRoute; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestRouteParsingErrors(t *testing.T) { + var err error + expect := func(msg string) { + _, _, line, _ := runtime.Caller(1) + if err == nil { + t.Errorf("line %v: expected an error", line) + return + } + if !strings.Contains(err.Error(), msg) { + t.Errorf("line %v: error %q does not contain: %q", line, err, msg) + } + } + ifcInfo := fakeInterfaceInfo() + _, err = parseWindowsRouteCommandOutput(ifcInfo, "", false) + expect("no IPv4 Route Table found") + _, err = parseWindowsRouteCommandOutput(ifcInfo, `IPv4 Route Table +=========================================================================== +`, false) + expect("no IPv4 Route Table found") + _, err = parseWindowsRouteCommandOutput(ifcInfo, `IPv4 Route Table +=========================================================================== +=========================================================================== +`, false) + expect("no IPv4 Route Table found") + _, err = parseWindowsRouteCommandOutput(ifcInfo, `IPv4 Route Table + =========================================================================== + Active Routes: + Network Destination Netmask Gateway Interface Metric + =========================================================================== + `, false) + expect("no IPv4 Route Table found") + + _, err = parseWindowsRouteCommandOutput(ifcInfo, `IPv4 Route Table +=========================================================================== +Active Routes: +Network Destination Netmask Gateway Interface Metric + 0.0.0.0 0.0.0.0 172.16.1.1 172.16.1.235 25 +`, false) + expect("failed to find end") + + _, err = parseWindowsRouteCommandOutput(ifcInfo, `IPv4 Route Table +=========================================================================== +Active Routes: +Network Destination Netmask Gateway Interface Metric + 0.0.0.0 0.0.0.0 172.16.1.1 172.16.1.235 25 +=========================================================================== +`, false) + expect("no IPv6 Route Table found") + + _, err = parseWindowsRouteCommandOutput(ifcInfo, `IPv4 Route Table +=========================================================================== +Active Routes: +Network Destination Netmask Gateway Interface Metric + 0.0.0.0 0.0.0.0 172.16.1.1 172.16.1.235 25 +=========================================================================== +IPv6 Route Table +=========================================================================== +Active Routes: + If Metric Network Destination Gateway + 3 281 fe80::4986:e542:6726:73ed/128 +`, false) + expect("failed to find end") +} diff --git a/netstate/netstate.go b/netstate/netstate.go index 6d98270..2d42818 100644 --- a/netstate/netstate.go +++ b/netstate/netstate.go @@ -408,11 +408,14 @@ func GetAllInterfaces() (InterfaceList, error) { } func (ifcl InterfaceList) String() string { - r := "" - for _, ifc := range ifcl { - r += fmt.Sprintf("%s, ", ifc) + out := strings.Builder{} + for i, ifc := range ifcl { + out.WriteString(ifc.String()) + if i+1 < len(ifcl) { + out.WriteString(", ") + } } - return strings.TrimRight(r, ", ") + return out.String() } // GetAccessibleIPs returns all of the accessible IP addresses on the device diff --git a/netstate/route_test.go b/netstate/route_test.go index 2b78c67..978cff1 100644 --- a/netstate/route_test.go +++ b/netstate/route_test.go @@ -27,9 +27,9 @@ func TestInterfaces(t *testing.T) { if got, want := len(ifcs), 1; got < want { t.Fatalf("got %v, want at least %v", got, want+1) } - str := ifcs.String() - if got, want := strings.Count(str, "("), len(ifcs); got != want { + if got, want := strings.Count(str, "flags"), len(ifcs); got < want { + t.Log(str) t.Fatalf("got %v, want %v", got, want) } } diff --git a/nsync/cv.go b/nsync/cv.go index 0c00a97..3b0f848 100644 --- a/nsync/cv.go +++ b/nsync/cv.go @@ -101,7 +101,7 @@ const ( // There are two reasons for using an absolute deadline, rather than a relative // timeout---these are why pthread_cond_timedwait() also uses an absolute // deadline. First, condition variable waits have to be used in a loop; with -// an absolute times, the deadline does not have to be recomputed on each +// an absolute time, the deadline does not have to be recomputed on each // iteration. Second, in most real programmes, some activity (such as an RPC // to a server, or when guaranteeing response time in a UI), there is a // deadline imposed by the specification or the caller/user; relative delays