New Upstream Snapshot - golang-github-rogpeppe-go-internal

Ready changes

Summary

Merged new upstream version: 1.9.0+git20230209.1.f0583b8 (was: 1.9.0).

Diff

diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index 5520980..0000000
--- a/.gitattributes
+++ /dev/null
@@ -1,10 +0,0 @@
-# By default, do not attempt to do any end-of-line conversion upon
-# checkin or checkout.
-*       -text
-
-# For specific file-types, force conversion to LF line endings. This
-# can be overridden below or in a more specific .gitattributes if, for
-# example, we want to allow a .txt file to contain a CRLF line ending
-*.go    text eol=lf
-*.txt   text eol=lf
-*.txtar text eol=lf
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index 5649f29..0000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,62 +0,0 @@
-on:
-  push:
-    branches:
-      - master
-  pull_request:
-    branches:
-      - '**'
-name: Test
-jobs:
-  test:
-    strategy:
-      fail-fast: false
-      matrix:
-        go-version: [1.17.x, 1.18.x]
-        os: [ubuntu-latest, macos-latest, windows-latest]
-    runs-on: ${{ matrix.os }}
-    steps:
-    - name: Install Go
-      uses: actions/setup-go@v3
-      with:
-        go-version: ${{ matrix.go-version }}
-    - name: Checkout code
-      uses: actions/checkout@v3
-    - name: Test
-      run: |
-        go test ./...
-        go test -race ./...
-
-    - name: Tidy
-      if: matrix.os == 'ubuntu-latest' # no need to do this everywhere
-      run: |
-        go mod tidy
-
-        test -z "$(gofmt -d .)" || (gofmt -d . && false)
-        test -z "$(git status --porcelain)" || (git status; git diff && false)
-
-  test-gotip:
-    runs-on: ubuntu-latest
-    continue-on-error: true # master breaks sometimes
-    steps:
-    - name: Install Go
-      env:
-        GO_COMMIT: 2cfbef438049fd4c3f73d1562773ad1f93900897 # 2022-06-09
-      run: |
-        cd $HOME
-        mkdir $HOME/gotip
-        cd $HOME/gotip
-
-        wget -O gotip.tar.gz https://go.googlesource.com/go/+archive/${GO_COMMIT}.tar.gz
-        tar -xf gotip.tar.gz
-        echo "devel go1.19-${GO_COMMIT}" >VERSION
-
-        cd src
-        ./make.bash
-        echo "GOROOT=$HOME/gotip" >>$GITHUB_ENV
-        echo "$HOME/gotip/bin" >>$GITHUB_PATH
-    - name: Checkout code
-      uses: actions/checkout@v3
-    - name: Test
-      run: |
-        go version
-        go test ./...
diff --git a/README.md b/README.md
index e21b28d..f2ab973 100644
--- a/README.md
+++ b/README.md
@@ -14,3 +14,7 @@ Included are the following:
 - testenv: information on the current testing environment.
 - testscript: script-based testing based on txtar files
 - txtar: simple text-based file archives for testing.
+
+# Links
+
+- [Test scripts in Go](https://bitfieldconsulting.com/golang/test-scripts)
diff --git a/cmd/testscript/help.go b/cmd/testscript/help.go
index d4808b0..0b3975c 100644
--- a/cmd/testscript/help.go
+++ b/cmd/testscript/help.go
@@ -14,7 +14,7 @@ The testscript command runs github.com/rogpeppe/go-internal/testscript scripts
 in a fresh temporary work directory tree.
 
 Usage:
-    testscript [-v] [-e VAR[=value]]... [-u] [-work] files...
+    testscript [-v] [-e VAR[=value]]... [-u] [-continue] [-work] files...
 
 The testscript command is designed to make it easy to create self-contained
 reproductions of command sequences.
@@ -42,6 +42,9 @@ succeed and the testscript file will be updated to reflect the actual content.
 As such, this is the cmd/testcript equivalent of
 testscript.Params.UpdateScripts.
 
+The -continue flag specifies that if an error occurs, the script will continue running.
+All errors will be printed and the exit status will be false.
+
 The -work flag prints the temporary work directory path before running each
 script, and does not remove that directory when testscript exits.
 
diff --git a/cmd/testscript/main.go b/cmd/testscript/main.go
index d3b1d25..6b2629d 100644
--- a/cmd/testscript/main.go
+++ b/cmd/testscript/main.go
@@ -65,6 +65,7 @@ func mainerr() (retErr error) {
 	var envVars envVarsFlag
 	fUpdate := fs.Bool("u", false, "update archive file if a cmp fails")
 	fWork := fs.Bool("work", false, "print temporary work directory and do not remove when done")
+	fContinue := fs.Bool("continue", false, "continue running the script if an error occurs")
 	fVerbose := fs.Bool("v", false, "run tests verbosely")
 	fs.Var(&envVars, "e", "pass through environment variable to script (can appear multiple times)")
 	if err := fs.Parse(os.Args[1:]); err != nil {
@@ -101,10 +102,11 @@ func mainerr() (retErr error) {
 	}
 
 	tr := testRunner{
-		update:   *fUpdate,
-		verbose:  *fVerbose,
-		env:      envVars.vals,
-		testWork: *fWork,
+		update:          *fUpdate,
+		continueOnError: *fContinue,
+		verbose:         *fVerbose,
+		env:             envVars.vals,
+		testWork:        *fWork,
 	}
 
 	dirNames := make(map[string]int)
@@ -139,6 +141,10 @@ type testRunner struct {
 	// updated in the case of any cmp failures.
 	update bool
 
+	// continueOnError indicates that T.FailNow should not panic, allowing the
+	// test script to continue running. Note that T is still marked as failed.
+	continueOnError bool
+
 	// verbose indicates the running of the script should be noisy.
 	verbose bool
 
@@ -203,8 +209,9 @@ func (tr *testRunner) run(runDir, filename string) error {
 	}
 
 	p := testscript.Params{
-		Dir:           runDir,
-		UpdateScripts: tr.update,
+		Dir:             runDir,
+		UpdateScripts:   tr.update,
+		ContinueOnError: tr.continueOnError,
 	}
 
 	if _, err := exec.LookPath("go"); err == nil {
@@ -271,7 +278,7 @@ func (tr *testRunner) run(runDir, filename string) error {
 		})
 	}
 
-	r := runT{
+	r := &runT{
 		verbose: tr.verbose,
 	}
 
@@ -286,6 +293,12 @@ func (tr *testRunner) run(runDir, filename string) error {
 			}
 		}()
 		testscript.RunT(r, p)
+
+		// When continueOnError is true, FailNow does not call panic(failedRun).
+		// We still want err to be set, as the script resulted in a failure.
+		if r.Failed() {
+			err = failedRun
+		}
 	}()
 
 	if err != nil {
@@ -338,40 +351,40 @@ type runT struct {
 	failed  int32
 }
 
-func (r runT) Skip(is ...interface{}) {
+func (r *runT) Skip(is ...interface{}) {
 	panic(skipRun)
 }
 
-func (r runT) Fatal(is ...interface{}) {
+func (r *runT) Fatal(is ...interface{}) {
 	r.Log(is...)
 	r.FailNow()
 }
 
-func (r runT) Parallel() {
+func (r *runT) Parallel() {
 	// No-op for now; we are currently only running a single script in a
 	// testscript instance.
 }
 
-func (r runT) Log(is ...interface{}) {
+func (r *runT) Log(is ...interface{}) {
 	fmt.Print(is...)
 }
 
-func (r runT) FailNow() {
+func (r *runT) FailNow() {
 	atomic.StoreInt32(&r.failed, 1)
 	panic(failedRun)
 }
 
-func (r runT) Failed() bool {
+func (r *runT) Failed() bool {
 	return atomic.LoadInt32(&r.failed) != 0
 }
 
-func (r runT) Run(n string, f func(t testscript.T)) {
+func (r *runT) Run(n string, f func(t testscript.T)) {
 	// For now we we don't top/tail the run of a subtest. We are currently only
 	// running a single script in a testscript instance, which means that we
 	// will only have a single subtest.
 	f(r)
 }
 
-func (r runT) Verbose() bool {
+func (r *runT) Verbose() bool {
 	return r.verbose
 }
diff --git a/cmd/testscript/testdata/continue.txt b/cmd/testscript/testdata/continue.txt
new file mode 100644
index 0000000..78f6211
--- /dev/null
+++ b/cmd/testscript/testdata/continue.txt
@@ -0,0 +1,16 @@
+# should support -continue
+unquote file.txt
+
+# Running with continue, the testscript command itself
+# should fail, but we should see the results of executing
+# both commands.
+! testscript -continue file.txt
+stdout 'grep banana in'
+stdout 'no match for `banana` found in in'
+stdout 'grep apple in'
+
+-- file.txt --
+>grep banana in
+>grep apple in
+>-- in --
+>apple
diff --git a/cmd/txtar-c/savedir.go b/cmd/txtar-c/savedir.go
index 07f0ab2..2c2246d 100644
--- a/cmd/txtar-c/savedir.go
+++ b/cmd/txtar-c/savedir.go
@@ -10,7 +10,6 @@
 //
 // See https://godoc.org/github.com/rogpeppe/go-internal/txtar for details of the format
 // and how to parse a txtar file.
-//
 package main
 
 import (
diff --git a/cmd/txtar-goproxy/main.go b/cmd/txtar-goproxy/main.go
index 161cc76..c4f0553 100644
--- a/cmd/txtar-goproxy/main.go
+++ b/cmd/txtar-goproxy/main.go
@@ -8,9 +8,9 @@
 // This allows interactive experimentation with the set of proxied modules.
 // For example:
 //
-// 	cd cmd/go
-// 	go test -proxy=localhost:1234 &
-// 	export GOPROXY=http://localhost:1234/mod
+//	cd cmd/go
+//	go test -proxy=localhost:1234 &
+//	export GOPROXY=http://localhost:1234/mod
 //
 // and then run go commands as usual.
 package main
diff --git a/cmd/txtar-x/extract.go b/cmd/txtar-x/extract.go
index 38d1a9f..ce3c975 100644
--- a/cmd/txtar-x/extract.go
+++ b/cmd/txtar-x/extract.go
@@ -10,7 +10,6 @@
 //
 // See https://godoc.org/github.com/rogpeppe/go-internal/txtar for details of the format
 // and how to parse a txtar file.
-//
 package main
 
 import (
diff --git a/debian/changelog b/debian/changelog
index 8fbc39b..a6d294d 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+golang-github-rogpeppe-go-internal (1.9.0+git20230209.1.f0583b8-1) UNRELEASED; urgency=low
+
+  * New upstream snapshot.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Sun, 26 Feb 2023 23:29:45 -0000
+
 golang-github-rogpeppe-go-internal (1.9.0-1) unstable; urgency=medium
 
   * New upstream version 1.9.0
diff --git a/fmtsort/sort.go b/fmtsort/sort.go
index 0fb5187..7f51854 100644
--- a/fmtsort/sort.go
+++ b/fmtsort/sort.go
@@ -36,19 +36,18 @@ func (o *SortedMap) Swap(i, j int) {
 //
 // The ordering rules are more general than with Go's < operator:
 //
-//  - when applicable, nil compares low
-//  - ints, floats, and strings order by <
-//  - NaN compares less than non-NaN floats
-//  - bool compares false before true
-//  - complex compares real, then imag
-//  - pointers compare by machine address
-//  - channel values compare by machine address
-//  - structs compare each field in turn
-//  - arrays compare each element in turn.
-//    Otherwise identical arrays compare by length.
-//  - interface values compare first by reflect.Type describing the concrete type
-//    and then by concrete value as described in the previous rules.
-//
+//   - when applicable, nil compares low
+//   - ints, floats, and strings order by <
+//   - NaN compares less than non-NaN floats
+//   - bool compares false before true
+//   - complex compares real, then imag
+//   - pointers compare by machine address
+//   - channel values compare by machine address
+//   - structs compare each field in turn
+//   - arrays compare each element in turn.
+//     Otherwise identical arrays compare by length.
+//   - interface values compare first by reflect.Type describing the concrete type
+//     and then by concrete value as described in the previous rules.
 func Sort(mapValue reflect.Value) *SortedMap {
 	if mapValue.Type().Kind() != reflect.Map {
 		return nil
diff --git a/go.mod b/go.mod
index 3da36bc..459c036 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,5 @@
 module github.com/rogpeppe/go-internal
 
-go 1.17
+go 1.18
 
 require github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e
diff --git a/gotooltest/setup.go b/gotooltest/setup.go
index 7e56df6..5ce75a1 100644
--- a/gotooltest/setup.go
+++ b/gotooltest/setup.go
@@ -10,6 +10,7 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"os"
 	"os/exec"
 	"path/filepath"
 	"regexp"
@@ -36,12 +37,30 @@ var (
 
 // initGoEnv initialises goEnv. It should only be called using goEnv.once.Do,
 // as in Setup.
-func initGoEnv() error {
-	var err error
+//
+// Run all of these probe commands in a temporary directory, so as not to make
+// any assumptions about the caller's working directory.
+func initGoEnv() (err error) {
+	td, err := os.MkdirTemp("", "gotooltest-initGoEnv")
+	if err != nil {
+		return fmt.Errorf("failed to create temporary directory for go command tests: %w", err)
+	}
+	defer func() {
+		if rerr := os.RemoveAll(td); rerr != nil && err == nil {
+			err = fmt.Errorf("failed to remove temporary directory for go command tests: %w", rerr)
+		}
+	}()
+
+	// Write a temporary go.mod file in td. This ensures that we create
+	// a porcelain environment in which to run these probe commands.
+	if err := os.WriteFile(filepath.Join(td, "go.mod"), []byte("module gotooltest"), 0600); err != nil {
+		return fmt.Errorf("failed to write temporary go.mod file: %w", err)
+	}
 
 	run := func(args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
 		var stdout, stderr bytes.Buffer
 		cmd := exec.Command(args[0], args[1:]...)
+		cmd.Dir = td
 		cmd.Stdout = &stdout
 		cmd.Stderr = &stderr
 		return &stdout, &stderr, cmd.Run()
diff --git a/gotooltest/setup_test.go b/gotooltest/setup_test.go
new file mode 100644
index 0000000..c8fc297
--- /dev/null
+++ b/gotooltest/setup_test.go
@@ -0,0 +1,30 @@
+package gotooltest
+
+import (
+	"os"
+	"testing"
+)
+
+func TestInitGoEnv(t *testing.T) {
+	// Set up a temp directory containing a bad go.mod file to
+	// ensure the working directory does not influence the probe
+	// commands run during initGoEnv
+	td := t.TempDir()
+
+	// If these commands fail we are in bigger trouble
+	wd, _ := os.Getwd()
+	os.Chdir(td)
+
+	t.Cleanup(func() {
+		os.Chdir(wd)
+		os.Remove(td)
+	})
+
+	if err := os.WriteFile("go.mod", []byte("this is rubbish"), 0600); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := initGoEnv(); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/gotooltest/testdata/cover.txt b/gotooltest/testdata/cover.txt
index b3d13e2..18a3b16 100644
--- a/gotooltest/testdata/cover.txt
+++ b/gotooltest/testdata/cover.txt
@@ -1,7 +1,5 @@
 unquote scripts/exec.txt
 
-[darwin] skip 'Pending a fix for github.com/rogpeppe/go-internal/issues/130'
-
 # The module uses testscript itself.
 # Use the checked out module, based on where the test binary ran.
 go mod edit -replace=github.com/rogpeppe/go-internal=${GOINTERNAL_MODULE}
@@ -10,18 +8,17 @@ go mod tidy
 # First, a 'go test' run without coverage.
 go test -vet=off
 stdout 'PASS'
-! stdout 'total coverage'
+! stdout 'coverage'
 
 # Then, a 'go test' run with -coverprofile.
-# Assuming testscript works well, this results in the basic coverage being 0%,
-# since the test binary does not directly run any non-test code.
-# The total coverage after merging profiles should end up being 100%,
-# as long as all three sub-profiles are accounted for.
+# The total coverage after merging profiles should end up being 100%.
 # Marking all printlns as covered requires all edge cases to work well.
+# Go 1.20 learned to produce and merge multiple coverage profiles,
+# so versions before then report a shallow 0% coverage.
 go test -vet=off -coverprofile=cover.out -v
 stdout 'PASS'
-stdout '^coverage: 0\.0%'
-stdout '^total coverage: 100\.0%'
+[go1.20] stdout 'coverage: 100\.0%'
+[!go1.20] stdout 'coverage: 0\.0%'
 ! stdout 'malformed coverage' # written by "go test" if cover.out is invalid
 exists cover.out
 
diff --git a/imports/build.go b/imports/build.go
index 5fd0106..891295c 100644
--- a/imports/build.go
+++ b/imports/build.go
@@ -32,7 +32,6 @@ var slashslash = []byte("//")
 // the purpose of satisfying build tags, in order to estimate
 // (conservatively) whether a file could ever possibly be used
 // in any build.
-//
 func ShouldBuild(content []byte, tags map[string]bool) bool {
 	// Pass 1. Identify leading run of // comments and blank lines,
 	// which must be followed by a blank line.
@@ -96,7 +95,6 @@ func ShouldBuild(content []byte, tags map[string]bool) bool {
 //	tag (if tags[tag] is true)
 //	!tag (if tags[tag] is false)
 //	a comma-separated list of any of these
-//
 func matchTags(name string, tags map[string]bool) bool {
 	if name == "" {
 		return false
@@ -145,12 +143,12 @@ func matchTag(name string, tags map[string]bool, want bool) bool {
 // suffix which does not match the current system.
 // The recognized name formats are:
 //
-//     name_$(GOOS).*
-//     name_$(GOARCH).*
-//     name_$(GOOS)_$(GOARCH).*
-//     name_$(GOOS)_test.*
-//     name_$(GOARCH)_test.*
-//     name_$(GOOS)_$(GOARCH)_test.*
+//	name_$(GOOS).*
+//	name_$(GOARCH).*
+//	name_$(GOOS)_$(GOARCH).*
+//	name_$(GOOS)_test.*
+//	name_$(GOARCH)_test.*
+//	name_$(GOOS)_$(GOARCH)_test.*
 //
 // An exception: if GOOS=android, then files with GOOS=linux are also matched.
 //
diff --git a/internal/syscall/windows/registry/key.go b/internal/syscall/windows/registry/key.go
index fb89e39..8ac59d2 100644
--- a/internal/syscall/windows/registry/key.go
+++ b/internal/syscall/windows/registry/key.go
@@ -23,7 +23,6 @@
 //
 // NOTE: This package is a copy of golang.org/x/sys/windows/registry
 // with KeyInfo.ModTime removed to prevent dependency cycles.
-//
 package registry
 
 import (
diff --git a/lockedfile/internal/filelock/filelock.go b/lockedfile/internal/filelock/filelock.go
index aba3eed..05f27c3 100644
--- a/lockedfile/internal/filelock/filelock.go
+++ b/lockedfile/internal/filelock/filelock.go
@@ -9,6 +9,7 @@ package filelock
 
 import (
 	"errors"
+	"io/fs"
 	"os"
 )
 
@@ -24,7 +25,7 @@ type File interface {
 	Fd() uintptr
 
 	// Stat returns the FileInfo structure describing file.
-	Stat() (os.FileInfo, error)
+	Stat() (fs.FileInfo, error)
 }
 
 // Lock places an advisory write lock on the file, blocking until it can be
@@ -87,7 +88,7 @@ var ErrNotSupported = errors.New("operation not supported")
 // underlyingError returns the underlying error for known os error types.
 func underlyingError(err error) error {
 	switch err := err.(type) {
-	case *os.PathError:
+	case *fs.PathError:
 		return err.Err
 	case *os.LinkError:
 		return err.Err
diff --git a/lockedfile/internal/filelock/filelock_fcntl.go b/lockedfile/internal/filelock/filelock_fcntl.go
index 4e64481..3098519 100644
--- a/lockedfile/internal/filelock/filelock_fcntl.go
+++ b/lockedfile/internal/filelock/filelock_fcntl.go
@@ -2,8 +2,8 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-//go:build aix || solaris
-// +build aix solaris
+//go:build aix || (solaris && !illumos)
+// +build aix solaris,!illumos
 
 // This code implements the filelock API using POSIX 'fcntl' locks, which attach
 // to an (inode, process) pair rather than a file descriptor. To avoid unlocking
@@ -13,17 +13,14 @@
 // Most platforms provide some alternative API, such as an 'flock' system call
 // or an F_OFD_SETLK command for 'fcntl', that allows for better concurrency and
 // does not require per-inode bookkeeping in the application.
-//
-// TODO(golang.org/issue/35618): add a syscall.Flock binding for Illumos and
-// switch it over to use filelock_unix.go.
 
 package filelock
 
 import (
 	"errors"
 	"io"
+	"io/fs"
 	"math/rand"
-	"os"
 	"sync"
 	"syscall"
 	"time"
@@ -43,8 +40,6 @@ type inodeLock struct {
 	queue []<-chan File
 }
 
-type token struct{}
-
 var (
 	mu     sync.Mutex
 	inodes = map[File]inode{}
@@ -65,7 +60,7 @@ func lock(f File, lt lockType) (err error) {
 	mu.Lock()
 	if i, dup := inodes[f]; dup && i != ino {
 		mu.Unlock()
-		return &os.PathError{
+		return &fs.PathError{
 			Op:   lt.String(),
 			Path: f.Name(),
 			Err:  errors.New("inode for file changed since last Lock or RLock"),
@@ -156,7 +151,7 @@ func lock(f File, lt lockType) (err error) {
 
 	if err != nil {
 		unlock(f)
-		return &os.PathError{
+		return &fs.PathError{
 			Op:   lt.String(),
 			Path: f.Name(),
 			Err:  err,
diff --git a/lockedfile/internal/filelock/filelock_other.go b/lockedfile/internal/filelock/filelock_other.go
index cfc5338..70f5d7a 100644
--- a/lockedfile/internal/filelock/filelock_other.go
+++ b/lockedfile/internal/filelock/filelock_other.go
@@ -7,7 +7,7 @@
 
 package filelock
 
-import "os"
+import "io/fs"
 
 type lockType int8
 
@@ -17,7 +17,7 @@ const (
 )
 
 func lock(f File, lt lockType) error {
-	return &os.PathError{
+	return &fs.PathError{
 		Op:   lt.String(),
 		Path: f.Name(),
 		Err:  ErrNotSupported,
@@ -25,7 +25,7 @@ func lock(f File, lt lockType) error {
 }
 
 func unlock(f File) error {
-	return &os.PathError{
+	return &fs.PathError{
 		Op:   "Unlock",
 		Path: f.Name(),
 		Err:  ErrNotSupported,
diff --git a/lockedfile/internal/filelock/filelock_plan9.go b/lockedfile/internal/filelock/filelock_plan9.go
index 5ae3cc2..908afb6 100644
--- a/lockedfile/internal/filelock/filelock_plan9.go
+++ b/lockedfile/internal/filelock/filelock_plan9.go
@@ -7,9 +7,7 @@
 
 package filelock
 
-import (
-	"os"
-)
+import "io/fs"
 
 type lockType int8
 
@@ -19,7 +17,7 @@ const (
 )
 
 func lock(f File, lt lockType) error {
-	return &os.PathError{
+	return &fs.PathError{
 		Op:   lt.String(),
 		Path: f.Name(),
 		Err:  ErrNotSupported,
@@ -27,7 +25,7 @@ func lock(f File, lt lockType) error {
 }
 
 func unlock(f File) error {
-	return &os.PathError{
+	return &fs.PathError{
 		Op:   "Unlock",
 		Path: f.Name(),
 		Err:  ErrNotSupported,
diff --git a/lockedfile/internal/filelock/filelock_test.go b/lockedfile/internal/filelock/filelock_test.go
index 925f641..c278636 100644
--- a/lockedfile/internal/filelock/filelock_test.go
+++ b/lockedfile/internal/filelock/filelock_test.go
@@ -2,14 +2,13 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-//go:build !js && !nacl && !plan9
-// +build !js,!nacl,!plan9
+//go:build !js && !plan9
+// +build !js,!plan9
 
 package filelock_test
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -17,8 +16,6 @@ import (
 	"testing"
 	"time"
 
-	"github.com/rogpeppe/go-internal/testenv"
-
 	"github.com/rogpeppe/go-internal/lockedfile/internal/filelock"
 )
 
@@ -53,9 +50,9 @@ func mustTempFile(t *testing.T) (f *os.File, remove func()) {
 	t.Helper()
 
 	base := filepath.Base(t.Name())
-	f, err := ioutil.TempFile("", base)
+	f, err := os.CreateTemp("", base)
 	if err != nil {
-		t.Fatalf(`ioutil.TempFile("", %q) = %v`, base, err)
+		t.Fatalf(`os.CreateTemp("", %q) = %v`, base, err)
 	}
 	t.Logf("fd %d = %s", f.Fd(), f.Name())
 
@@ -161,7 +158,9 @@ func TestRLockExcludesOnlyLock(t *testing.T) {
 	f2 := mustOpen(t, f.Name())
 	defer f2.Close()
 
-	if runtime.GOOS == "solaris" || runtime.GOOS == "aix" {
+	doUnlockTF := false
+	switch runtime.GOOS {
+	case "aix", "solaris":
 		// When using POSIX locks (as on Solaris), we can't safely read-lock the
 		// same inode through two different descriptors at the same time: when the
 		// first descriptor is closed, the second descriptor would still be open but
@@ -169,8 +168,9 @@ func TestRLockExcludesOnlyLock(t *testing.T) {
 		lockF2 := mustBlock(t, "RLock", f2)
 		unlock(t, f)
 		lockF2(t)
-	} else {
+	default:
 		rLock(t, f2)
+		doUnlockTF = true
 	}
 
 	other := mustOpen(t, f.Name())
@@ -178,7 +178,7 @@ func TestRLockExcludesOnlyLock(t *testing.T) {
 	lockOther := mustBlock(t, "Lock", other)
 
 	unlock(t, f2)
-	if runtime.GOOS != "solaris" && runtime.GOOS != "aix" {
+	if doUnlockTF {
 		unlock(t, f)
 	}
 	lockOther(t)
@@ -186,8 +186,6 @@ func TestRLockExcludesOnlyLock(t *testing.T) {
 }
 
 func TestLockNotDroppedByExecCommand(t *testing.T) {
-	testenv.MustHaveExec(t)
-
 	f, remove := mustTempFile(t)
 	defer remove()
 
diff --git a/lockedfile/internal/filelock/filelock_unix.go b/lockedfile/internal/filelock/filelock_unix.go
index 09549ef..878a1e7 100644
--- a/lockedfile/internal/filelock/filelock_unix.go
+++ b/lockedfile/internal/filelock/filelock_unix.go
@@ -2,13 +2,13 @@
 // 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 || linux || netbsd || openbsd
-// +build darwin dragonfly freebsd linux netbsd openbsd
+//go:build darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd
+// +build darwin dragonfly freebsd illumos linux netbsd openbsd
 
 package filelock
 
 import (
-	"os"
+	"io/fs"
 	"syscall"
 )
 
@@ -27,7 +27,7 @@ func lock(f File, lt lockType) (err error) {
 		}
 	}
 	if err != nil {
-		return &os.PathError{
+		return &fs.PathError{
 			Op:   lt.String(),
 			Path: f.Name(),
 			Err:  err,
diff --git a/lockedfile/internal/filelock/filelock_windows.go b/lockedfile/internal/filelock/filelock_windows.go
index 2bd3eb9..1454aca 100644
--- a/lockedfile/internal/filelock/filelock_windows.go
+++ b/lockedfile/internal/filelock/filelock_windows.go
@@ -8,7 +8,7 @@
 package filelock
 
 import (
-	"os"
+	"io/fs"
 	"syscall"
 
 	"github.com/rogpeppe/go-internal/internal/syscall/windows"
@@ -36,7 +36,7 @@ func lock(f File, lt lockType) error {
 
 	err := windows.LockFileEx(syscall.Handle(f.Fd()), uint32(lt), reserved, allBytes, allBytes, ol)
 	if err != nil {
-		return &os.PathError{
+		return &fs.PathError{
 			Op:   lt.String(),
 			Path: f.Name(),
 			Err:  err,
@@ -49,7 +49,7 @@ func unlock(f File) error {
 	ol := new(syscall.Overlapped)
 	err := windows.UnlockFileEx(syscall.Handle(f.Fd()), reserved, allBytes, allBytes, ol)
 	if err != nil {
-		return &os.PathError{
+		return &fs.PathError{
 			Op:   "Unlock",
 			Path: f.Name(),
 			Err:  err,
diff --git a/lockedfile/lockedfile.go b/lockedfile/lockedfile.go
index 88f95b4..82e1a89 100644
--- a/lockedfile/lockedfile.go
+++ b/lockedfile/lockedfile.go
@@ -9,7 +9,7 @@ package lockedfile
 import (
 	"fmt"
 	"io"
-	"io/ioutil"
+	"io/fs"
 	"os"
 	"runtime"
 )
@@ -35,7 +35,7 @@ type osFile struct {
 // OpenFile is like os.OpenFile, but returns a locked file.
 // If flag includes os.O_WRONLY or os.O_RDWR, the file is write-locked;
 // otherwise, it is read-locked.
-func OpenFile(name string, flag int, perm os.FileMode) (*File, error) {
+func OpenFile(name string, flag int, perm fs.FileMode) (*File, error) {
 	var (
 		f   = new(File)
 		err error
@@ -64,16 +64,16 @@ func Open(name string) (*File, error) {
 
 // Create is like os.Create, but returns a write-locked file.
 func Create(name string) (*File, error) {
-	return OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o666)
+	return OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
 }
 
-// Edit creates the named file with mode 0o666 (before umask),
+// Edit creates the named file with mode 0666 (before umask),
 // but does not truncate existing contents.
 //
 // If Edit succeeds, methods on the returned File can be used for I/O.
 // The associated file descriptor has mode O_RDWR and the file is write-locked.
 func Edit(name string) (*File, error) {
-	return OpenFile(name, os.O_RDWR|os.O_CREATE, 0o666)
+	return OpenFile(name, os.O_RDWR|os.O_CREATE, 0666)
 }
 
 // Close unlocks and closes the underlying file.
@@ -82,10 +82,10 @@ func Edit(name string) (*File, error) {
 // non-nil error.
 func (f *File) Close() error {
 	if f.closed {
-		return &os.PathError{
+		return &fs.PathError{
 			Op:   "close",
 			Path: f.Name(),
-			Err:  os.ErrClosed,
+			Err:  fs.ErrClosed,
 		}
 	}
 	f.closed = true
@@ -103,12 +103,12 @@ func Read(name string) ([]byte, error) {
 	}
 	defer f.Close()
 
-	return ioutil.ReadAll(f)
+	return io.ReadAll(f)
 }
 
 // Write opens the named file (creating it with the given permissions if needed),
 // then write-locks it and overwrites it with the given content.
-func Write(name string, content io.Reader, perm os.FileMode) (err error) {
+func Write(name string, content io.Reader, perm fs.FileMode) (err error) {
 	f, err := OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
 	if err != nil {
 		return err
@@ -120,3 +120,68 @@ func Write(name string, content io.Reader, perm os.FileMode) (err error) {
 	}
 	return err
 }
+
+// Transform invokes t with the result of reading the named file, with its lock
+// still held.
+//
+// If t returns a nil error, Transform then writes the returned contents back to
+// the file, making a best effort to preserve existing contents on error.
+//
+// t must not modify the slice passed to it.
+func Transform(name string, t func([]byte) ([]byte, error)) (err error) {
+	f, err := Edit(name)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	old, err := io.ReadAll(f)
+	if err != nil {
+		return err
+	}
+
+	new, err := t(old)
+	if err != nil {
+		return err
+	}
+
+	if len(new) > len(old) {
+		// The overall file size is increasing, so write the tail first: if we're
+		// about to run out of space on the disk, we would rather detect that
+		// failure before we have overwritten the original contents.
+		if _, err := f.WriteAt(new[len(old):], int64(len(old))); err != nil {
+			// Make a best effort to remove the incomplete tail.
+			f.Truncate(int64(len(old)))
+			return err
+		}
+	}
+
+	// We're about to overwrite the old contents. In case of failure, make a best
+	// effort to roll back before we close the file.
+	defer func() {
+		if err != nil {
+			if _, err := f.WriteAt(old, 0); err == nil {
+				f.Truncate(int64(len(old)))
+			}
+		}
+	}()
+
+	if len(new) >= len(old) {
+		if _, err := f.WriteAt(new[:len(old)], 0); err != nil {
+			return err
+		}
+	} else {
+		if _, err := f.WriteAt(new, 0); err != nil {
+			return err
+		}
+		// The overall file size is decreasing, so shrink the file to its final size
+		// after writing. We do this after writing (instead of before) so that if
+		// the write fails, enough filesystem space will likely still be reserved
+		// to contain the previous contents.
+		if err := f.Truncate(int64(len(new))); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/lockedfile/lockedfile_filelock.go b/lockedfile/lockedfile_filelock.go
index 6a03173..454c3a4 100644
--- a/lockedfile/lockedfile_filelock.go
+++ b/lockedfile/lockedfile_filelock.go
@@ -3,17 +3,17 @@
 // license that can be found in the LICENSE file.
 
 //go:build !plan9
-// +build !plan9
 
 package lockedfile
 
 import (
+	"io/fs"
 	"os"
 
 	"github.com/rogpeppe/go-internal/lockedfile/internal/filelock"
 )
 
-func openFile(name string, flag int, perm os.FileMode) (*os.File, error) {
+func openFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
 	// On BSD systems, we could add the O_SHLOCK or O_EXLOCK flag to the OpenFile
 	// call instead of locking separately, but we have to support separate locking
 	// calls for Linux and Windows anyway, so it's simpler to use that approach
diff --git a/lockedfile/lockedfile_plan9.go b/lockedfile/lockedfile_plan9.go
index 02221c5..a2ce794 100644
--- a/lockedfile/lockedfile_plan9.go
+++ b/lockedfile/lockedfile_plan9.go
@@ -3,11 +3,11 @@
 // license that can be found in the LICENSE file.
 
 //go:build plan9
-// +build plan9
 
 package lockedfile
 
 import (
+	"io/fs"
 	"math/rand"
 	"os"
 	"strings"
@@ -17,9 +17,9 @@ import (
 // Opening an exclusive-use file returns an error.
 // The expected error strings are:
 //
-//  - "open/create -- file is locked" (cwfs, kfs)
-//  - "exclusive lock" (fossil)
-//  - "exclusive use file already open" (ramfs)
+//   - "open/create -- file is locked" (cwfs, kfs)
+//   - "exclusive lock" (fossil)
+//   - "exclusive use file already open" (ramfs)
 var lockedErrStrings = [...]string{
 	"file is locked",
 	"exclusive lock",
@@ -42,7 +42,7 @@ func isLocked(err error) bool {
 	return false
 }
 
-func openFile(name string, flag int, perm os.FileMode) (*os.File, error) {
+func openFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
 	// Plan 9 uses a mode bit instead of explicit lock/unlock syscalls.
 	//
 	// Per http://man.cat-v.org/plan_9/5/stat: “Exclusive use files may be open
@@ -57,8 +57,8 @@ func openFile(name string, flag int, perm os.FileMode) (*os.File, error) {
 	// have the ModeExclusive bit set. Set it before we call OpenFile, so that we
 	// can be confident that a successful OpenFile implies exclusive use.
 	if fi, err := os.Stat(name); err == nil {
-		if fi.Mode()&os.ModeExclusive == 0 {
-			if err := os.Chmod(name, fi.Mode()|os.ModeExclusive); err != nil {
+		if fi.Mode()&fs.ModeExclusive == 0 {
+			if err := os.Chmod(name, fi.Mode()|fs.ModeExclusive); err != nil {
 				return nil, err
 			}
 		}
@@ -69,7 +69,7 @@ func openFile(name string, flag int, perm os.FileMode) (*os.File, error) {
 	nextSleep := 1 * time.Millisecond
 	const maxSleep = 500 * time.Millisecond
 	for {
-		f, err := os.OpenFile(name, flag, perm|os.ModeExclusive)
+		f, err := os.OpenFile(name, flag, perm|fs.ModeExclusive)
 		if err == nil {
 			return f, nil
 		}
diff --git a/lockedfile/lockedfile_test.go b/lockedfile/lockedfile_test.go
index bc51de5..39cd7c9 100644
--- a/lockedfile/lockedfile_test.go
+++ b/lockedfile/lockedfile_test.go
@@ -2,30 +2,27 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// js and nacl do not support inter-process file locking.
-//go:build !js && !nacl
-// +build !js,!nacl
+// js does not support inter-process file locking.
+//
+//go:build !js
 
 package lockedfile_test
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
 	"os/exec"
 	"path/filepath"
 	"testing"
 	"time"
 
-	"github.com/rogpeppe/go-internal/testenv"
-
 	"github.com/rogpeppe/go-internal/lockedfile"
 )
 
 func mustTempDir(t *testing.T) (dir string, remove func()) {
 	t.Helper()
 
-	dir, err := ioutil.TempDir("", filepath.Base(t.Name()))
+	dir, err := os.MkdirTemp("", filepath.Base(t.Name()))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -157,8 +154,8 @@ func TestCanLockExistingFile(t *testing.T) {
 	defer remove()
 	path := filepath.Join(dir, "existing.txt")
 
-	if err := ioutil.WriteFile(path, []byte("ok"), 0o777); err != nil {
-		t.Fatalf("ioutil.WriteFile: %v", err)
+	if err := os.WriteFile(path, []byte("ok"), 0777); err != nil {
+		t.Fatalf("os.WriteFile: %v", err)
 	}
 
 	f, err := lockedfile.Edit(path)
@@ -191,8 +188,6 @@ func TestSpuriousEDEADLK(t *testing.T) {
 	// 	P.2 unblocks and locks file B.
 	// 	P.2 unlocks file B.
 
-	testenv.MustHaveExec(t)
-
 	dirVar := t.Name() + "DIR"
 
 	if dir := os.Getenv(dirVar); dir != "" {
@@ -203,7 +198,7 @@ func TestSpuriousEDEADLK(t *testing.T) {
 		}
 		defer b.Close()
 
-		if err := ioutil.WriteFile(filepath.Join(dir, "locked"), []byte("ok"), 0o666); err != nil {
+		if err := os.WriteFile(filepath.Join(dir, "locked"), []byte("ok"), 0666); err != nil {
 			t.Fatal(err)
 		}
 
diff --git a/lockedfile/mutex.go b/lockedfile/mutex.go
index de3be57..180a36c 100644
--- a/lockedfile/mutex.go
+++ b/lockedfile/mutex.go
@@ -7,6 +7,7 @@ package lockedfile
 import (
 	"fmt"
 	"os"
+	"sync"
 )
 
 // A Mutex provides mutual exclusion within and across processes by locking a
@@ -21,7 +22,8 @@ import (
 // must not be copied after first use. The Path field must be set before first
 // use and must not be change thereafter.
 type Mutex struct {
-	Path string // The path to the well-known lock file. Must be non-empty.
+	Path string     // The path to the well-known lock file. Must be non-empty.
+	mu   sync.Mutex // A redundant mutex. The race detector doesn't know about file locking, so in tests we may need to lock something that it understands.
 }
 
 // MutexAt returns a new Mutex with Path set to the given non-empty path.
@@ -52,9 +54,14 @@ func (mu *Mutex) Lock() (unlock func(), err error) {
 	// in the future, it should call OpenFile with O_RDONLY and will require the
 	// files must be readable, so we should not let the caller make any
 	// assumptions about Mutex working with write-only files.
-	f, err := OpenFile(mu.Path, os.O_RDWR|os.O_CREATE, 0o666)
+	f, err := OpenFile(mu.Path, os.O_RDWR|os.O_CREATE, 0666)
 	if err != nil {
 		return nil, err
 	}
-	return func() { f.Close() }, nil
+	mu.mu.Lock()
+
+	return func() {
+		mu.mu.Unlock()
+		f.Close()
+	}, nil
 }
diff --git a/lockedfile/transform_test.go b/lockedfile/transform_test.go
new file mode 100644
index 0000000..f7488fa
--- /dev/null
+++ b/lockedfile/transform_test.go
@@ -0,0 +1,105 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// js does not support inter-process file locking.
+//
+//go:build !js
+
+package lockedfile_test
+
+import (
+	"bytes"
+	"encoding/binary"
+	"math/rand"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/rogpeppe/go-internal/lockedfile"
+)
+
+func isPowerOf2(x int) bool {
+	return x > 0 && x&(x-1) == 0
+}
+
+func roundDownToPowerOf2(x int) int {
+	if x <= 0 {
+		panic("nonpositive x")
+	}
+	bit := 1
+	for x != bit {
+		x = x &^ bit
+		bit <<= 1
+	}
+	return x
+}
+
+func TestTransform(t *testing.T) {
+	dir, remove := mustTempDir(t)
+	defer remove()
+	path := filepath.Join(dir, "blob.bin")
+
+	const maxChunkWords = 8 << 10
+	buf := make([]byte, 2*maxChunkWords*8)
+	for i := uint64(0); i < 2*maxChunkWords; i++ {
+		binary.LittleEndian.PutUint64(buf[i*8:], i)
+	}
+	if err := lockedfile.Write(path, bytes.NewReader(buf[:8]), 0666); err != nil {
+		t.Fatal(err)
+	}
+
+	var attempts int64 = 128
+	if !testing.Short() {
+		attempts *= 16
+	}
+	const parallel = 32
+
+	var sem = make(chan bool, parallel)
+
+	for n := attempts; n > 0; n-- {
+		sem <- true
+		go func() {
+			defer func() { <-sem }()
+
+			time.Sleep(time.Duration(rand.Intn(100)) * time.Microsecond)
+			chunkWords := roundDownToPowerOf2(rand.Intn(maxChunkWords) + 1)
+			offset := rand.Intn(chunkWords)
+
+			err := lockedfile.Transform(path, func(data []byte) (chunk []byte, err error) {
+				chunk = buf[offset*8 : (offset+chunkWords)*8]
+
+				if len(data)&^7 != len(data) {
+					t.Errorf("read %d bytes, but each write is an integer multiple of 8 bytes", len(data))
+					return chunk, nil
+				}
+
+				words := len(data) / 8
+				if !isPowerOf2(words) {
+					t.Errorf("read %d 8-byte words, but each write is a power-of-2 number of words", words)
+					return chunk, nil
+				}
+
+				u := binary.LittleEndian.Uint64(data)
+				for i := 1; i < words; i++ {
+					next := binary.LittleEndian.Uint64(data[i*8:])
+					if next != u+1 {
+						t.Errorf("wrote sequential integers, but read integer out of sequence at offset %d", i)
+						return chunk, nil
+					}
+					u = next
+				}
+
+				return chunk, nil
+			})
+
+			if err != nil {
+				t.Errorf("unexpected error from Transform: %v", err)
+			}
+		}()
+	}
+
+	for n := parallel; n > 0; n-- {
+		sem <- true
+	}
+}
diff --git a/modfile/read.go b/modfile/read.go
index 1d81ff1..0e5fa3e 100644
--- a/modfile/read.go
+++ b/modfile/read.go
@@ -244,7 +244,6 @@ func (x *Line) Span() (start, end Position) {
 //		"x"
 //		"y"
 //	)
-//
 type LineBlock struct {
 	Comments
 	Start  Position
diff --git a/testenv/testenv.go b/testenv/testenv.go
index 8f69fe0..bcac6a5 100644
--- a/testenv/testenv.go
+++ b/testenv/testenv.go
@@ -30,7 +30,7 @@ func Builder() string {
 	return os.Getenv("GO_BUILDER_NAME")
 }
 
-// HasGoBuild reports whether the current system can build programs with ``go build''
+// HasGoBuild reports whether the current system can build programs with “go build”
 // and then run them with os.StartProcess or exec.Command.
 func HasGoBuild() bool {
 	if os.Getenv("GO_GCFLAGS") != "" {
@@ -51,7 +51,7 @@ func HasGoBuild() bool {
 	return true
 }
 
-// MustHaveGoBuild checks that the current system can build programs with ``go build''
+// MustHaveGoBuild checks that the current system can build programs with “go build”
 // and then run them with os.StartProcess or exec.Command.
 // If not, MustHaveGoBuild calls t.Skip with an explanation.
 func MustHaveGoBuild(t testing.TB) {
@@ -63,13 +63,13 @@ func MustHaveGoBuild(t testing.TB) {
 	}
 }
 
-// HasGoRun reports whether the current system can run programs with ``go run.''
+// HasGoRun reports whether the current system can run programs with “go run.”
 func HasGoRun() bool {
 	// For now, having go run and having go build are the same.
 	return HasGoBuild()
 }
 
-// MustHaveGoRun checks that the current system can run programs with ``go run.''
+// MustHaveGoRun checks that the current system can run programs with “go run.”
 // If not, MustHaveGoRun calls t.Skip with an explanation.
 func MustHaveGoRun(t testing.TB) {
 	if !HasGoRun() {
diff --git a/testscript/cmd.go b/testscript/cmd.go
index 2cf63f7..87c44dc 100644
--- a/testscript/cmd.go
+++ b/testscript/cmd.go
@@ -253,7 +253,7 @@ func (ts *TestScript) cmdExec(neg bool, args []string) {
 		if err == nil {
 			wait := make(chan struct{})
 			go func() {
-				ctxWait(ts.ctxt, cmd)
+				waitOrStop(ts.ctxt, cmd, -1)
 				close(wait)
 			}()
 			ts.background = append(ts.background, backgroundCmd{bgName, cmd, wait, neg})
diff --git a/testscript/cover.go b/testscript/cover.go
deleted file mode 100644
index 181605b..0000000
--- a/testscript/cover.go
+++ /dev/null
@@ -1,280 +0,0 @@
-// Copyright 2018 The Go 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 testscript
-
-import (
-	"bufio"
-	"errors"
-	"fmt"
-	"io"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strconv"
-	"strings"
-	"sync/atomic"
-	"testing"
-)
-
-// mergeCoverProfile merges the coverage information in f into
-// cover. It assumes that the coverage information in f is
-// always produced from the same binary for every call.
-func mergeCoverProfile(cover *testing.Cover, path string) error {
-	f, err := os.Open(path)
-	if err != nil {
-		return err
-	}
-	defer f.Close()
-	scanner, err := newProfileScanner(f)
-	if err != nil {
-		return err
-	}
-	if scanner.Mode() != testing.CoverMode() {
-		return errors.New("unexpected coverage mode in subcommand")
-	}
-	if cover.Mode == "" {
-		cover.Mode = scanner.Mode()
-	}
-	isCount := cover.Mode == "count"
-	if cover.Counters == nil {
-		cover.Counters = make(map[string][]uint32)
-		cover.Blocks = make(map[string][]testing.CoverBlock)
-	}
-
-	// Note that we rely on the fact that the coverage is written
-	// out file-by-file, with all blocks for a file in sequence.
-	var (
-		filename string
-		blockId  uint32
-		counters []uint32
-		blocks   []testing.CoverBlock
-	)
-	flush := func() {
-		if len(counters) > 0 {
-			cover.Counters[filename] = counters
-			cover.Blocks[filename] = blocks
-		}
-	}
-	for scanner.Scan() {
-		block := scanner.Block()
-		if scanner.Filename() != filename {
-			flush()
-			filename = scanner.Filename()
-			counters = cover.Counters[filename]
-			blocks = cover.Blocks[filename]
-			blockId = 0
-		} else {
-			blockId++
-		}
-		if int(blockId) >= len(counters) {
-			counters = append(counters, block.Count)
-			blocks = append(blocks, block.CoverBlock)
-			continue
-		}
-		// TODO check that block.CoverBlock == blocks[blockId] ?
-		if isCount {
-			counters[blockId] += block.Count
-		} else {
-			counters[blockId] |= block.Count
-		}
-	}
-	flush()
-	if scanner.Err() != nil {
-		return fmt.Errorf("error scanning profile: %v", err)
-	}
-	return nil
-}
-
-func finalizeCoverProfile(dir string) error {
-	// Merge all the coverage profiles written by test binary sub-processes,
-	// such as those generated by executions of commands.
-	var cover testing.Cover
-	if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-		if !info.Mode().IsRegular() {
-			return nil
-		}
-		if err := mergeCoverProfile(&cover, path); err != nil {
-			return fmt.Errorf("cannot merge coverage profile from %v: %v", path, err)
-		}
-		return nil
-	}); err != nil {
-		return err
-	}
-	if err := os.RemoveAll(dir); err != nil {
-		// The RemoveAll seems to fail very rarely, with messages like
-		// "directory not empty". It's unclear why.
-		// For now, if it happens again, try to print a bit more info.
-		filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
-			if err == nil && !info.IsDir() {
-				fmt.Fprintln(os.Stderr, "non-directory found after RemoveAll:", path)
-			}
-			return nil
-		})
-		return err
-	}
-
-	// We need to include our own top-level coverage profile too.
-	cprof := coverProfile()
-	if err := mergeCoverProfile(&cover, cprof); err != nil {
-		return fmt.Errorf("cannot merge coverage profile from %v: %v", cprof, err)
-	}
-
-	// Finally, write the resulting merged profile.
-	f, err := os.Create(cprof)
-	if err != nil {
-		return fmt.Errorf("cannot create cover profile: %v", err)
-	}
-	defer f.Close()
-	w := bufio.NewWriter(f)
-	if err := writeCoverProfile1(w, cover); err != nil {
-		return err
-	}
-	if err := w.Flush(); err != nil {
-		return err
-	}
-	if err := f.Close(); err != nil {
-		return err
-	}
-	return nil
-}
-
-func writeCoverProfile1(w io.Writer, cover testing.Cover) error {
-	fmt.Fprintf(w, "mode: %s\n", cover.Mode)
-	var active, total int64
-	var count uint32
-	for name, counts := range cover.Counters {
-		blocks := cover.Blocks[name]
-		for i := range counts {
-			stmts := int64(blocks[i].Stmts)
-			total += stmts
-			count = atomic.LoadUint32(&counts[i]) // For -mode=atomic.
-			if count > 0 {
-				active += stmts
-			}
-			_, err := fmt.Fprintf(w, "%s:%d.%d,%d.%d %d %d\n", name,
-				blocks[i].Line0, blocks[i].Col0,
-				blocks[i].Line1, blocks[i].Col1,
-				stmts,
-				count,
-			)
-			if err != nil {
-				return err
-			}
-		}
-	}
-	if total == 0 {
-		total = 1
-	}
-	fmt.Printf("total coverage: %.1f%% of statements%s\n", 100*float64(active)/float64(total), cover.CoveredPackages)
-	return nil
-}
-
-type profileScanner struct {
-	mode     string
-	err      error
-	scanner  *bufio.Scanner
-	filename string
-	block    coverBlock
-}
-
-type coverBlock struct {
-	testing.CoverBlock
-	Count uint32
-}
-
-var profileLineRe = regexp.MustCompile(`^(.+):([0-9]+)\.([0-9]+),([0-9]+)\.([0-9]+) ([0-9]+) ([0-9]+)$`)
-
-func toInt(s string) int {
-	i, err := strconv.Atoi(s)
-	if err != nil {
-		panic(err)
-	}
-	return i
-}
-
-func newProfileScanner(r io.Reader) (*profileScanner, error) {
-	s := &profileScanner{
-		scanner: bufio.NewScanner(r),
-	}
-	// First line is "mode: foo", where foo is "set", "count", or "atomic".
-	// Rest of file is in the format
-	//	encoding/base64/base64.go:34.44,37.40 3 1
-	// where the fields are: name.go:line.column,line.column numberOfStatements count
-	if !s.scanner.Scan() {
-		return nil, fmt.Errorf("no lines found in profile: %v", s.Err())
-	}
-	line := s.scanner.Text()
-	mode := strings.TrimPrefix(line, "mode: ")
-	if len(mode) == len(line) {
-		return nil, fmt.Errorf("bad mode line %q", line)
-	}
-	s.mode = mode
-	return s, nil
-}
-
-// Mode returns the profile's coverage mode (one of "atomic", "count:
-// or "set").
-func (s *profileScanner) Mode() string {
-	return s.mode
-}
-
-// Err returns any error encountered when scanning a profile.
-func (s *profileScanner) Err() error {
-	if s.err == io.EOF {
-		return nil
-	}
-	return s.err
-}
-
-// Block returns the most recently scanned profile block, or the zero
-// block if Scan has not been called or has returned false.
-func (s *profileScanner) Block() coverBlock {
-	if s.err == nil {
-		return s.block
-	}
-	return coverBlock{}
-}
-
-// Filename returns the filename of the most recently scanned profile
-// block, or the empty string if Scan has not been called or has
-// returned false.
-func (s *profileScanner) Filename() string {
-	if s.err == nil {
-		return s.filename
-	}
-	return ""
-}
-
-// Scan scans the next line in a coverage profile and reports whether
-// a line was found.
-func (s *profileScanner) Scan() bool {
-	if s.err != nil {
-		return false
-	}
-	if !s.scanner.Scan() {
-		s.err = io.EOF
-		return false
-	}
-	m := profileLineRe.FindStringSubmatch(s.scanner.Text())
-	if m == nil {
-		s.err = fmt.Errorf("line %q doesn't match expected format %v", m, profileLineRe)
-		return false
-	}
-	s.filename = m[1]
-	s.block = coverBlock{
-		CoverBlock: testing.CoverBlock{
-			Line0: uint32(toInt(m[2])),
-			Col0:  uint16(toInt(m[3])),
-			Line1: uint32(toInt(m[4])),
-			Col1:  uint16(toInt(m[5])),
-			Stmts: uint16(toInt(m[6])),
-		},
-		Count: uint32(toInt(m[7])),
-	}
-	return true
-}
diff --git a/testscript/exe.go b/testscript/exe.go
index 46c611a..ed6bd98 100644
--- a/testscript/exe.go
+++ b/testscript/exe.go
@@ -5,9 +5,6 @@
 package testscript
 
 import (
-	cryptorand "crypto/rand"
-	"flag"
-	"fmt"
 	"io"
 	"io/ioutil"
 	"log"
@@ -24,15 +21,8 @@ type TestingM interface {
 	Run() int
 }
 
-var ignoreMissedCoverage = false
-
-// IgnoreMissedCoverage causes any missed coverage information
-// (for example when a function passed to RunMain
-// calls os.Exit, for example) to be ignored.
-// This function should be called before calling RunMain.
-func IgnoreMissedCoverage() {
-	ignoreMissedCoverage = true
-}
+// Deprecated: this option is no longer used.
+func IgnoreMissedCoverage() {}
 
 // RunMain should be called within a TestMain function to allow
 // subcommands to be run in the testscript context.
@@ -84,26 +74,6 @@ func RunMain(m TestingM, commands map[string]func() int) (exitCode int) {
 		}
 		os.Setenv("PATH", bindir+string(filepath.ListSeparator)+os.Getenv("PATH"))
 
-		flag.Parse()
-		// If we are collecting a coverage profile, set up a shared
-		// directory for all executed test binary sub-processes to write
-		// their profiles to. Before finishing, we'll merge all of those
-		// profiles into the main profile.
-		if coverProfile() != "" {
-			coverdir := filepath.Join(tmpdir, "cover")
-			if err := os.MkdirAll(coverdir, 0o777); err != nil {
-				log.Printf("could not set up cover directory: %v", err)
-				return 2
-			}
-			os.Setenv("TESTSCRIPT_COVER_DIR", coverdir)
-			defer func() {
-				if err := finalizeCoverProfile(coverdir); err != nil {
-					log.Printf("cannot merge cover profiles: %v", err)
-					exitCode = 2
-				}
-			}()
-		}
-
 		// We're not in a subcommand.
 		for name := range commands {
 			name := name
@@ -131,31 +101,7 @@ func RunMain(m TestingM, commands map[string]func() int) (exitCode int) {
 	}
 	// The command being registered is being invoked, so run it, then exit.
 	os.Args[0] = cmdName
-	coverdir := os.Getenv("TESTSCRIPT_COVER_DIR")
-	if coverdir == "" {
-		// No coverage, act as normal.
-		return mainf()
-	}
-
-	// For a command "foo", write ${TESTSCRIPT_COVER_DIR}/foo-${RANDOM}.
-	// Note that we do not use ioutil.TempFile as that creates the file.
-	// In this case, we want to leave it to -test.coverprofile to create the
-	// file, as otherwise we could end up with an empty file.
-	// Later, when merging profiles, trying to merge an empty file would
-	// result in a confusing error.
-	rnd, err := nextRandom()
-	if err != nil {
-		log.Printf("could not obtain random number: %v", err)
-		return 2
-	}
-	cprof := filepath.Join(coverdir, fmt.Sprintf("%s-%x", cmdName, rnd))
-	return runCoverSubcommand(cprof, mainf)
-}
-
-func nextRandom() ([]byte, error) {
-	p := make([]byte, 6)
-	_, err := cryptorand.Read(p)
-	return p, err
+	return mainf()
 }
 
 // copyBinary makes a copy of a binary to a new location. It is used as part of
@@ -197,78 +143,6 @@ func copyBinary(from, to string) error {
 	return err
 }
 
-// runCoverSubcommand runs the given function, then writes any generated
-// coverage information to the cprof file.
-// This is called inside a separately run executable.
-func runCoverSubcommand(cprof string, mainf func() int) (exitCode int) {
-	// Change the error handling mode to PanicOnError
-	// so that in the common case of calling flag.Parse in main we'll
-	// be able to catch the panic instead of just exiting.
-	flag.CommandLine.Init(flag.CommandLine.Name(), flag.PanicOnError)
-	defer func() {
-		panicErr := recover()
-		if err, ok := panicErr.(error); ok {
-			// The flag package will already have printed this error, assuming,
-			// that is, that the error was created in the flag package.
-			// TODO check the stack to be sure it was actually raised by the flag package.
-			exitCode = 2
-			if err == flag.ErrHelp {
-				exitCode = 0
-			}
-			panicErr = nil
-		}
-		// Set os.Args so that flag.Parse will tell testing the correct
-		// coverprofile setting. Unfortunately this isn't sufficient because
-		// the testing oackage explicitly avoids calling flag.Parse again
-		// if flag.Parsed returns true, so we the coverprofile value directly
-		// too.
-		os.Args = []string{os.Args[0], "-test.coverprofile=" + cprof}
-		setCoverProfile(cprof)
-
-		// Suppress the chatty coverage and test report.
-		devNull, err := os.Open(os.DevNull)
-		if err != nil {
-			panic(err)
-		}
-		os.Stdout = devNull
-		os.Stderr = devNull
-
-		// Run MainStart (recursively, but it we should be ok) with no tests
-		// so that it writes the coverage profile.
-		m := mainStart()
-		if code := m.Run(); code != 0 && exitCode == 0 {
-			exitCode = code
-		}
-		if _, err := os.Stat(cprof); err != nil {
-			log.Printf("failed to write coverage profile %q", cprof)
-		}
-		if panicErr != nil {
-			// The error didn't originate from the flag package (we know that
-			// flag.PanicOnError causes an error value that implements error),
-			// so carry on panicking.
-			panic(panicErr)
-		}
-	}()
-	return mainf()
-}
-
-func coverProfileFlag() flag.Getter {
-	f := flag.CommandLine.Lookup("test.coverprofile")
-	if f == nil {
-		// We've imported testing so it definitely should be there.
-		panic("cannot find test.coverprofile flag")
-	}
-	return f.Value.(flag.Getter)
-}
-
-func coverProfile() string {
-	return coverProfileFlag().Get().(string)
-}
-
-func setCoverProfile(cprof string) {
-	coverProfileFlag().Set(cprof)
-}
-
 type nopTestDeps struct{}
 
 func (nopTestDeps) MatchString(pat, str string) (result bool, err error) {
diff --git a/testscript/testdata/testscript_duplicate_name.txt b/testscript/testdata/testscript_duplicate_name.txt
new file mode 100644
index 0000000..b8fd4c6
--- /dev/null
+++ b/testscript/testdata/testscript_duplicate_name.txt
@@ -0,0 +1,12 @@
+# Check that RequireUniqueNames works;
+# it should reject txtar archives with duplicate names as defined by the host system.
+
+unquote scripts-normalized/testscript.txt
+
+testscript scripts-normalized
+! testscript -unique-names scripts-normalized
+stdout '.* would overwrite .* \(because RequireUniqueNames is enabled\)'
+
+-- scripts-normalized/testscript.txt --
+>-- file --
+>-- dir/../file --
\ No newline at end of file
diff --git a/testscript/testdata/testscript_logging.txt b/testscript/testdata/testscript_logging.txt
new file mode 100644
index 0000000..60975fe
--- /dev/null
+++ b/testscript/testdata/testscript_logging.txt
@@ -0,0 +1,108 @@
+# non-verbose, non-continue
+! testscript scripts
+cmpenv stdout expect-stdout.txt
+
+# verbose
+! testscript -v scripts
+cmpenv stdout expect-stdout-v.txt
+
+# continue
+! testscript -continue scripts
+cmpenv stdout expect-stdout-c.txt
+
+# verbose, continue
+! testscript -v -continue scripts
+cmpenv stdout expect-stdout-vc.txt
+
+-- scripts/testscript.txt --
+# comment 1
+printargs section1
+
+# comment 2
+printargs section2
+
+# comment 3
+printargs section3
+status 1
+
+# comment 4
+printargs section3
+
+# comment 5
+printargs section5
+status 1
+
+-- expect-stdout.txt --
+# comment 1 (0.000s)
+# comment 2 (0.000s)
+# comment 3 (0.000s)
+> printargs section3
+[stdout]
+["printargs" "section3"]
+> status 1
+[exit status 1]
+FAIL: $$WORK${/}scripts${/}testscript.txt:9: unexpected command failure
+-- expect-stdout-v.txt --
+# comment 1 (0.000s)
+> printargs section1
+[stdout]
+["printargs" "section1"]
+# comment 2 (0.000s)
+> printargs section2
+[stdout]
+["printargs" "section2"]
+# comment 3 (0.000s)
+> printargs section3
+[stdout]
+["printargs" "section3"]
+> status 1
+[exit status 1]
+FAIL: $$WORK${/}scripts${/}testscript.txt:9: unexpected command failure
+-- expect-stdout-c.txt --
+# comment 1 (0.000s)
+# comment 2 (0.000s)
+# comment 3 (0.000s)
+> printargs section3
+[stdout]
+["printargs" "section3"]
+> status 1
+[exit status 1]
+FAIL: $$WORK${/}scripts${/}testscript.txt:9: unexpected command failure
+# comment 4 (0.000s)
+> printargs section3
+[stdout]
+["printargs" "section3"]
+# comment 5 (0.000s)
+> printargs section5
+[stdout]
+["printargs" "section5"]
+> status 1
+[exit status 1]
+FAIL: $$WORK${/}scripts${/}testscript.txt:16: unexpected command failure
+-- expect-stdout-vc.txt --
+# comment 1 (0.000s)
+> printargs section1
+[stdout]
+["printargs" "section1"]
+# comment 2 (0.000s)
+> printargs section2
+[stdout]
+["printargs" "section2"]
+# comment 3 (0.000s)
+> printargs section3
+[stdout]
+["printargs" "section3"]
+> status 1
+[exit status 1]
+FAIL: $$WORK${/}scripts${/}testscript.txt:9: unexpected command failure
+# comment 4 (0.000s)
+> printargs section3
+[stdout]
+["printargs" "section3"]
+# comment 5 (0.000s)
+> printargs section5
+[stdout]
+["printargs" "section5"]
+> status 1
+[exit status 1]
+FAIL: $$WORK${/}scripts${/}testscript.txt:16: unexpected command failure
diff --git a/testscript/testscript.go b/testscript/testscript.go
index 2b0cd75..f21d267 100644
--- a/testscript/testscript.go
+++ b/testscript/testscript.go
@@ -10,9 +10,11 @@ package testscript
 import (
 	"bytes"
 	"context"
+	"errors"
 	"flag"
 	"fmt"
 	"go/build"
+	"io/fs"
 	"io/ioutil"
 	"os"
 	"os/exec"
@@ -21,6 +23,7 @@ import (
 	"runtime"
 	"strings"
 	"sync/atomic"
+	"syscall"
 	"testing"
 	"time"
 
@@ -40,6 +43,16 @@ var execCache par.Cache
 // poke at the test file tree afterward.
 var testWork = flag.Bool("testwork", false, "")
 
+// timeSince is defined as a variable so that it can be overridden
+// for the local testscript tests so that we can test against predictable
+// output.
+var timeSince = time.Since
+
+// showVerboseEnv specifies whether the environment should be displayed
+// automatically when in verbose mode. This is set to false for the local testscript tests so we
+// can test against predictable output.
+var showVerboseEnv = true
+
 // Env holds the environment to use at the start of a test script invocation.
 type Env struct {
 	// WorkDir holds the path to the root directory of the
@@ -139,11 +152,7 @@ type Params struct {
 	// $GOTMPDIR/go-test-script*, where $GOTMPDIR defaults to os.TempDir().
 	WorkdirRoot string
 
-	// IgnoreMissedCoverage specifies that if coverage information
-	// is being generated (with the -test.coverprofile flag) and a subcommand
-	// function passed to RunMain fails to generate coverage information
-	// (for example because the function invoked os.Exit), then the
-	// error will be ignored.
+	// Deprecated: this option is no longer used.
 	IgnoreMissedCoverage bool
 
 	// UpdateScripts specifies that if a `cmp` command fails and its second
@@ -161,11 +170,28 @@ type Params struct {
 	// consistency across test scripts as well as keep separate process
 	// executions explicit.
 	RequireExplicitExec bool
+
+	// RequireUniqueNames requires that names in the txtar archive are unique.
+	// By default, later entries silently overwrite earlier ones.
+	RequireUniqueNames bool
+
+	// ContinueOnError causes a testscript to try to continue in
+	// the face of errors. Once an error has occurred, the script
+	// will continue as if in verbose mode.
+	ContinueOnError bool
+
+	// Deadline, if not zero, specifies the time at which the test run will have
+	// exceeded the timeout. It is equivalent to testing.T's Deadline method,
+	// and Run will set it to the method's return value if this field is zero.
+	Deadline time.Time
 }
 
 // RunDir runs the tests in the given directory. All files in dir with a ".txt"
 // or ".txtar" extension are considered to be test files.
 func Run(t *testing.T, p Params) {
+	if deadline, ok := t.Deadline(); ok && p.Deadline.IsZero() {
+		p.Deadline = deadline
+	}
 	RunT(tshim{t}, p)
 }
 
@@ -240,6 +266,37 @@ func RunT(t T, p Params) {
 	if err != nil {
 		t.Fatal(err)
 	}
+
+	var (
+		ctx         = context.Background()
+		gracePeriod = 100 * time.Millisecond
+		cancel      context.CancelFunc
+	)
+	if !p.Deadline.IsZero() {
+		timeout := time.Until(p.Deadline)
+
+		// If time allows, increase the termination grace period to 5% of the
+		// remaining time.
+		if gp := timeout / 20; gp > gracePeriod {
+			gracePeriod = gp
+		}
+
+		// When we run commands that execute subprocesses, we want to reserve two
+		// grace periods to clean up. We will send the first termination signal when
+		// the context expires, then wait one grace period for the process to
+		// produce whatever useful output it can (such as a stack trace). After the
+		// first grace period expires, we'll escalate to os.Kill, leaving the second
+		// grace period for the test function to record its output before the test
+		// process itself terminates.
+		timeout -= 2 * gracePeriod
+
+		ctx, cancel = context.WithTimeout(ctx, timeout)
+		// We don't defer cancel() because RunT returns before the sub-tests,
+		// and we don't have access to Cleanup due to the T interface. Instead,
+		// we call it after the refCount goes to zero below.
+		_ = cancel
+	}
+
 	refCount := int32(len(files))
 	for _, file := range files {
 		file := file
@@ -253,7 +310,8 @@ func RunT(t T, p Params) {
 				name:          name,
 				file:          file,
 				params:        p,
-				ctxt:          context.Background(),
+				ctxt:          ctx,
+				gracePeriod:   gracePeriod,
 				deferred:      func() {},
 				scriptFiles:   make(map[string]string),
 				scriptUpdates: make(map[string]string),
@@ -265,8 +323,11 @@ func RunT(t T, p Params) {
 				removeAll(ts.workdir)
 				if atomic.AddInt32(&refCount, -1) == 0 {
 					// This is the last subtest to finish. Remove the
-					// parent directory too.
+					// parent directory too, and cancel the context.
 					os.Remove(testTempDir)
+					if cancel != nil {
+						cancel()
+					}
 				}
 			}()
 			ts.run()
@@ -301,7 +362,8 @@ type TestScript struct {
 	scriptFiles   map[string]string           // files stored in the txtar archive (absolute paths -> path in script)
 	scriptUpdates map[string]string           // updates to testscript files via UpdateScripts.
 
-	ctxt context.Context // per TestScript context
+	ctxt        context.Context // per TestScript context
+	gracePeriod time.Duration   // time between SIGQUIT and SIGKILL
 }
 
 type backgroundCmd struct {
@@ -311,9 +373,30 @@ type backgroundCmd struct {
 	neg  bool // if true, cmd should fail
 }
 
+func writeFile(name string, data []byte, perm fs.FileMode, excl bool) error {
+	oflags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
+	if excl {
+		oflags |= os.O_EXCL
+	}
+	f, err := os.OpenFile(name, oflags, perm)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	if _, err := f.Write(data); err != nil {
+		return fmt.Errorf("cannot write file contents: %v", err)
+	}
+	return nil
+}
+
 // setup sets up the test execution temporary directory and environment.
 // It returns the comment section of the txtar archive.
 func (ts *TestScript) setup() string {
+	defer catchFailNow(func() {
+		// There's been a failure in setup; fail immediately regardless
+		// of the ContinueOnError flag.
+		ts.t.FailNow()
+	})
 	ts.workdir = filepath.Join(ts.testTempDir, "script-"+ts.name)
 
 	// Establish a temporary directory in workdir, but use a prefix that ensures
@@ -330,6 +413,7 @@ func (ts *TestScript) setup() string {
 		Vars: []string{
 			"WORK=" + ts.workdir, // must be first for ts.abbrev
 			"PATH=" + os.Getenv("PATH"),
+			"GOTRACEBACK=system",
 			homeEnvName() + "=/no-home",
 			tempEnvName() + "=" + tmpDir,
 			"devnull=" + os.DevNull,
@@ -339,7 +423,7 @@ func (ts *TestScript) setup() string {
 
 			// If we are collecting coverage profiles for merging into the main one,
 			// ensure the environment variable is forwarded to sub-processes.
-			"TESTSCRIPT_COVER_DIR=" + os.Getenv("TESTSCRIPT_COVER_DIR"),
+			"GOCOVERDIR=" + os.Getenv("GOCOVERDIR"),
 		},
 		WorkDir: ts.workdir,
 		Values:  make(map[interface{}]interface{}),
@@ -366,7 +450,12 @@ func (ts *TestScript) setup() string {
 		name := ts.MkAbs(ts.expand(f.Name))
 		ts.scriptFiles[name] = f.Name
 		ts.Check(os.MkdirAll(filepath.Dir(name), 0o777))
-		ts.Check(ioutil.WriteFile(name, f.Data, 0o666))
+		switch err := writeFile(name, f.Data, 0o666, ts.params.RequireUniqueNames); {
+		case ts.params.RequireUniqueNames && errors.Is(err, fs.ErrExist):
+			ts.Check(fmt.Errorf("%s would overwrite %s (because RequireUniqueNames is enabled)", f.Name, name))
+		default:
+			ts.Check(err)
+		}
 	}
 	// Run any user-defined setup.
 	if ts.params.Setup != nil {
@@ -389,8 +478,9 @@ func (ts *TestScript) setup() string {
 func (ts *TestScript) run() {
 	// Truncate log at end of last phase marker,
 	// discarding details of successful phase.
+	verbose := ts.t.Verbose()
 	rewind := func() {
-		if !ts.t.Verbose() {
+		if !verbose {
 			ts.log.Truncate(ts.mark)
 		}
 	}
@@ -400,12 +490,13 @@ func (ts *TestScript) run() {
 		if ts.mark > 0 && !ts.start.IsZero() {
 			afterMark := append([]byte{}, ts.log.Bytes()[ts.mark:]...)
 			ts.log.Truncate(ts.mark - 1) // cut \n and afterMark
-			fmt.Fprintf(&ts.log, " (%.3fs)\n", time.Since(ts.start).Seconds())
+			fmt.Fprintf(&ts.log, " (%.3fs)\n", timeSince(ts.start).Seconds())
 			ts.log.Write(afterMark)
 		}
 		ts.start = time.Time{}
 	}
 
+	failed := false
 	defer func() {
 		// On a normal exit from the test loop, background processes are cleaned up
 		// before we print PASS. If we return early (e.g., due to a test failure),
@@ -413,7 +504,7 @@ func (ts *TestScript) run() {
 		for _, bg := range ts.background {
 			interruptProcess(bg.cmd.Process)
 		}
-		if ts.t.Verbose() || hasFailed(ts.t) {
+		if ts.t.Verbose() || failed {
 			// In verbose mode or on test failure, we want to see what happened in the background
 			// processes too.
 			ts.waitBackground(false)
@@ -434,7 +525,7 @@ func (ts *TestScript) run() {
 	script := ts.setup()
 
 	// With -v or -testwork, start log with full environment.
-	if *testWork || ts.t.Verbose() {
+	if *testWork || (showVerboseEnv && ts.t.Verbose()) {
 		// Display environment.
 		ts.cmdEnv(false, nil)
 		fmt.Fprintf(&ts.log, "\n")
@@ -444,7 +535,6 @@ func (ts *TestScript) run() {
 
 	// Run script.
 	// See testdata/script/README for documentation of script form.
-Script:
 	for script != "" {
 		// Extract next line.
 		ts.lineno++
@@ -458,7 +548,9 @@ Script:
 		// # is a comment indicating the start of new phase.
 		if strings.HasPrefix(line, "#") {
 			// If there was a previous phase, it succeeded,
-			// so rewind the log to delete its details (unless -v is in use).
+			// so rewind the log to delete its details (unless -v is in use or
+			// ContinueOnError was enabled and there was a previous error,
+			// causing verbose to be set to true).
 			// If nothing has happened at all since the mark,
 			// rewinding is a no-op and adding elapsed time
 			// for doing nothing is meaningless, so don't.
@@ -473,60 +565,16 @@ Script:
 			continue
 		}
 
-		// Parse input line. Ignore blanks entirely.
-		args := ts.parse(line)
-		if len(args) == 0 {
-			continue
-		}
-
-		// Echo command to log.
-		fmt.Fprintf(&ts.log, "> %s\n", line)
-
-		// Command prefix [cond] means only run this command if cond is satisfied.
-		for strings.HasPrefix(args[0], "[") && strings.HasSuffix(args[0], "]") {
-			cond := args[0]
-			cond = cond[1 : len(cond)-1]
-			cond = strings.TrimSpace(cond)
-			args = args[1:]
-			if len(args) == 0 {
-				ts.Fatalf("missing command after condition")
-			}
-			want := true
-			if strings.HasPrefix(cond, "!") {
-				want = false
-				cond = strings.TrimSpace(cond[1:])
-			}
-			ok, err := ts.condition(cond)
-			if err != nil {
-				ts.Fatalf("bad condition %q: %v", cond, err)
-			}
-			if ok != want {
-				// Don't run rest of line.
-				continue Script
+		ok := ts.runLine(line)
+		if !ok {
+			failed = true
+			if ts.params.ContinueOnError {
+				verbose = true
+			} else {
+				ts.t.FailNow()
 			}
 		}
 
-		// Command prefix ! means negate the expectations about this command:
-		// go command should fail, match should not be found, etc.
-		neg := false
-		if args[0] == "!" {
-			neg = true
-			args = args[1:]
-			if len(args) == 0 {
-				ts.Fatalf("! on line by itself")
-			}
-		}
-
-		// Run command.
-		cmd := scriptCmds[args[0]]
-		if cmd == nil {
-			cmd = ts.params.Cmds[args[0]]
-		}
-		if cmd == nil {
-			ts.Fatalf("unknown command %q", args[0])
-		}
-		cmd(ts, neg, args[1:])
-
 		// Command can ask script to stop early.
 		if ts.stopped {
 			// Break instead of returning, so that we check the status of any
@@ -540,6 +588,12 @@ Script:
 	}
 	ts.cmdWait(false, nil)
 
+	// If we reached here but we've failed (probably because ContinueOnError
+	// was set), don't wipe the log and print "PASS".
+	if failed {
+		ts.t.FailNow()
+	}
+
 	// Final phase ended.
 	rewind()
 	markTime()
@@ -548,11 +602,65 @@ Script:
 	}
 }
 
-func hasFailed(t T) bool {
-	if t, ok := t.(TFailed); ok {
-		return t.Failed()
+func (ts *TestScript) runLine(line string) (runOK bool) {
+	defer catchFailNow(func() {
+		runOK = false
+	})
+
+	// Parse input line. Ignore blanks entirely.
+	args := ts.parse(line)
+	if len(args) == 0 {
+		return true
+	}
+
+	// Echo command to log.
+	fmt.Fprintf(&ts.log, "> %s\n", line)
+
+	// Command prefix [cond] means only run this command if cond is satisfied.
+	for strings.HasPrefix(args[0], "[") && strings.HasSuffix(args[0], "]") {
+		cond := args[0]
+		cond = cond[1 : len(cond)-1]
+		cond = strings.TrimSpace(cond)
+		args = args[1:]
+		if len(args) == 0 {
+			ts.Fatalf("missing command after condition")
+		}
+		want := true
+		if strings.HasPrefix(cond, "!") {
+			want = false
+			cond = strings.TrimSpace(cond[1:])
+		}
+		ok, err := ts.condition(cond)
+		if err != nil {
+			ts.Fatalf("bad condition %q: %v", cond, err)
+		}
+		if ok != want {
+			// Don't run rest of line.
+			return true
+		}
+	}
+
+	// Command prefix ! means negate the expectations about this command:
+	// go command should fail, match should not be found, etc.
+	neg := false
+	if args[0] == "!" {
+		neg = true
+		args = args[1:]
+		if len(args) == 0 {
+			ts.Fatalf("! on line by itself")
+		}
 	}
-	return false
+
+	// Run command.
+	cmd := scriptCmds[args[0]]
+	if cmd == nil {
+		cmd = ts.params.Cmds[args[0]]
+	}
+	if cmd == nil {
+		ts.Fatalf("unknown command %q", args[0])
+	}
+	cmd(ts, neg, args[1:])
+	return true
 }
 
 func (ts *TestScript) applyScriptUpdates() {
@@ -570,7 +678,7 @@ func (ts *TestScript) applyScriptUpdates() {
 			if txtar.NeedsQuote(data) {
 				data1, err := txtar.Quote(data)
 				if err != nil {
-					ts.t.Fatal(fmt.Sprintf("cannot update script file %q: %v", f.Name, err))
+					ts.Fatalf("cannot update script file %q: %v", f.Name, err)
 					continue
 				}
 				data = data1
@@ -589,6 +697,21 @@ func (ts *TestScript) applyScriptUpdates() {
 	ts.Logf("%s updated", ts.file)
 }
 
+var failNow = errors.New("fail now!")
+
+// catchFailNow catches any panic from Fatalf and calls
+// f if it did so. It must be called in a defer.
+func catchFailNow(f func()) {
+	e := recover()
+	if e == nil {
+		return
+	}
+	if e != failNow {
+		panic(e)
+	}
+	f()
+}
+
 // condition reports whether the given condition is satisfied.
 func (ts *TestScript) condition(cond string) (bool, error) {
 	switch {
@@ -686,7 +809,7 @@ func (ts *TestScript) exec(command string, args ...string) (stdout, stderr strin
 	cmd.Stdout = &stdoutBuf
 	cmd.Stderr = &stderrBuf
 	if err = cmd.Start(); err == nil {
-		err = ctxWait(ts.ctxt, cmd)
+		err = waitOrStop(ts.ctxt, cmd, ts.gracePeriod)
 	}
 	ts.stdin = ""
 	return stdoutBuf.String(), stderrBuf.String(), err
@@ -731,21 +854,68 @@ func (ts *TestScript) BackgroundCmds() []*exec.Cmd {
 	return cmds
 }
 
-// ctxWait is like cmd.Wait, but terminates cmd with os.Interrupt if ctx becomes done.
+// waitOrStop waits for the already-started command cmd by calling its Wait method.
 //
-// This differs from exec.CommandContext in that it prefers os.Interrupt over os.Kill.
-// (See https://golang.org/issue/21135.)
-func ctxWait(ctx context.Context, cmd *exec.Cmd) error {
-	errc := make(chan error, 1)
-	go func() { errc <- cmd.Wait() }()
-
-	select {
-	case err := <-errc:
-		return err
-	case <-ctx.Done():
-		interruptProcess(cmd.Process)
-		return <-errc
+// If cmd does not return before ctx is done, waitOrStop sends it an interrupt
+// signal. If killDelay is positive, waitOrStop waits that additional period for
+// Wait to return before sending os.Kill.
+func waitOrStop(ctx context.Context, cmd *exec.Cmd, killDelay time.Duration) error {
+	if cmd.Process == nil {
+		panic("waitOrStop called with a nil cmd.Process — missing Start call?")
+	}
+
+	errc := make(chan error)
+	go func() {
+		select {
+		case errc <- nil:
+			return
+		case <-ctx.Done():
+		}
+
+		var interrupt os.Signal = syscall.SIGQUIT
+		if runtime.GOOS == "windows" {
+			// Per https://golang.org/pkg/os/#Signal, “Interrupt is not implemented on
+			// Windows; using it with os.Process.Signal will return an error.”
+			// Fall back directly to Kill instead.
+			interrupt = os.Kill
+		}
+
+		err := cmd.Process.Signal(interrupt)
+		if err == nil {
+			err = ctx.Err() // Report ctx.Err() as the reason we interrupted.
+		} else if err == os.ErrProcessDone {
+			errc <- nil
+			return
+		}
+
+		if killDelay > 0 {
+			timer := time.NewTimer(killDelay)
+			select {
+			// Report ctx.Err() as the reason we interrupted the process...
+			case errc <- ctx.Err():
+				timer.Stop()
+				return
+			// ...but after killDelay has elapsed, fall back to a stronger signal.
+			case <-timer.C:
+			}
+
+			// Wait still hasn't returned.
+			// Kill the process harder to make sure that it exits.
+			//
+			// Ignore any error: if cmd.Process has already terminated, we still
+			// want to send ctx.Err() (or the error from the Interrupt call)
+			// to properly attribute the signal that may have terminated it.
+			_ = cmd.Process.Kill()
+		}
+
+		errc <- err
+	}()
+
+	waitErr := cmd.Wait()
+	if interruptErr := <-errc; interruptErr != nil {
+		return interruptErr
 	}
+	return waitErr
 }
 
 // interruptProcess sends os.Interrupt to p if supported, or os.Kill otherwise.
@@ -785,7 +955,10 @@ func (ts *TestScript) expand(s string) string {
 // fatalf aborts the test with the given failure message.
 func (ts *TestScript) Fatalf(format string, args ...interface{}) {
 	fmt.Fprintf(&ts.log, "FAIL: %s:%d: %s\n", ts.file, ts.lineno, fmt.Sprintf(format, args...))
-	ts.t.FailNow()
+	// This should be caught by the defer inside the TestScript.runLine method.
+	// We do this rather than calling ts.t.FailNow directly because we want to
+	// be able to continue on error when Params.ContinueOnError is set.
+	panic(failNow)
 }
 
 // MkAbs interprets file relative to the test script's current directory
diff --git a/testscript/testscript_test.go b/testscript/testscript_test.go
index c09cabb..53f420a 100644
--- a/testscript/testscript_test.go
+++ b/testscript/testscript_test.go
@@ -59,6 +59,11 @@ func signalCatcher() int {
 }
 
 func TestMain(m *testing.M) {
+	timeSince = func(t time.Time) time.Duration {
+		return 0
+	}
+
+	showVerboseEnv = false
 	os.Exit(RunMain(m, map[string]func() int{
 		"printargs":     printArgs,
 		"fprintargs":    fprintArgs,
@@ -130,6 +135,31 @@ func TestEnv(t *testing.T) {
 	}
 }
 
+func TestSetupFailure(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "foo.txt"), nil, 0o666); err != nil {
+		t.Fatal(err)
+	}
+	ft := &fakeT{}
+	func() {
+		defer catchAbort()
+		RunT(ft, Params{
+			Dir: dir,
+			Setup: func(*Env) error {
+				return fmt.Errorf("some failure")
+			},
+		})
+	}()
+	if !ft.failed {
+		t.Fatal("test should have failed because of setup failure")
+	}
+
+	want := regexp.MustCompile(`^FAIL: .*: some failure\n$`)
+	if got := ft.log.String(); !want.MatchString(got) {
+		t.Fatalf("expected msg to match `%v`; got:\n%q", want, got)
+	}
+}
+
 func TestScripts(t *testing.T) {
 	// TODO set temp directory.
 	testDeferCount := 0
@@ -183,42 +213,40 @@ func TestScripts(t *testing.T) {
 				fset := flag.NewFlagSet("testscript", flag.ContinueOnError)
 				fUpdate := fset.Bool("update", false, "update scripts when cmp fails")
 				fExplicitExec := fset.Bool("explicit-exec", false, "require explicit use of exec for commands")
-				fVerbose := fset.Bool("verbose", false, "be verbose with output")
+				fUniqueNames := fset.Bool("unique-names", false, "require unique names in txtar archive")
+				fVerbose := fset.Bool("v", false, "be verbose with output")
+				fContinue := fset.Bool("continue", false, "continue on error")
 				if err := fset.Parse(args); err != nil {
 					ts.Fatalf("failed to parse args for testscript: %v", err)
 				}
 				if fset.NArg() != 1 {
-					ts.Fatalf("testscript [-verbose] [-update] [-explicit-exec] <dir>")
+					ts.Fatalf("testscript [-v] [-continue] [-update] [-explicit-exec] <dir>")
 				}
 				dir := fset.Arg(0)
-				t := &fakeT{ts: ts, verbose: *fVerbose}
+				t := &fakeT{verbose: *fVerbose}
 				func() {
-					defer func() {
-						if err := recover(); err != nil {
-							if err != errAbort {
-								panic(err)
-							}
-						}
-					}()
+					defer catchAbort()
 					RunT(t, Params{
 						Dir:                 ts.MkAbs(dir),
 						UpdateScripts:       *fUpdate,
 						RequireExplicitExec: *fExplicitExec,
+						RequireUniqueNames:  *fUniqueNames,
 						Cmds: map[string]func(ts *TestScript, neg bool, args []string){
 							"some-param-cmd": func(ts *TestScript, neg bool, args []string) {
 							},
 						},
+						ContinueOnError: *fContinue,
 					})
 				}()
 				ts.stdout = strings.Replace(t.log.String(), ts.workdir, "$WORK", -1)
 				if neg {
-					if len(t.failMsgs) == 0 {
+					if !t.failed {
 						ts.Fatalf("testscript unexpectedly succeeded")
 					}
 					return
 				}
-				if len(t.failMsgs) > 0 {
-					ts.Fatalf("testscript unexpectedly failed with errors: %q", t.failMsgs)
+				if t.failed {
+					ts.Fatalf("testscript unexpectedly failed with errors: %q", &t.log)
 				}
 			},
 		},
@@ -303,24 +331,21 @@ func TestWorkdirRoot(t *testing.T) {
 func TestBadDir(t *testing.T) {
 	ft := new(fakeT)
 	func() {
-		defer func() {
-			if err := recover(); err != nil {
-				if err != errAbort {
-					panic(err)
-				}
-			}
-		}()
+		defer catchAbort()
 		RunT(ft, Params{
 			Dir: "thiswillnevermatch",
 		})
 	}()
-	wantCount := 1
-	if got := len(ft.failMsgs); got != wantCount {
-		t.Fatalf("expected %v fail message; got %v", wantCount, got)
+	want := regexp.MustCompile(`no txtar nor txt scripts found in dir thiswillnevermatch`)
+	if got := ft.log.String(); !want.MatchString(got) {
+		t.Fatalf("expected msg to match `%v`; got:\n%v", want, got)
 	}
-	wantMsg := regexp.MustCompile(`no txtar nor txt scripts found in dir thiswillnevermatch`)
-	if got := ft.failMsgs[0]; !wantMsg.MatchString(got) {
-		t.Fatalf("expected msg to match `%v`; got:\n%v", wantMsg, got)
+}
+
+// catchAbort catches the panic raised by fakeT.FailNow.
+func catchAbort() {
+	if err := recover(); err != nil && err != errAbort {
+		panic(err)
 	}
 }
 
@@ -391,11 +416,9 @@ func waitFile(ts *TestScript, neg bool, args []string) {
 }
 
 type fakeT struct {
-	ts       *TestScript
-	log      bytes.Buffer
-	failMsgs []string
-	verbose  bool
-	failed   bool
+	log     strings.Builder
+	verbose bool
+	failed  bool
 }
 
 var errAbort = errors.New("abort test")
@@ -405,9 +428,8 @@ func (t *fakeT) Skip(args ...interface{}) {
 }
 
 func (t *fakeT) Fatal(args ...interface{}) {
-	t.failed = true
-	t.failMsgs = append(t.failMsgs, fmt.Sprint(args...))
-	panic(errAbort)
+	t.Log(args...)
+	t.FailNow()
 }
 
 func (t *fakeT) Parallel() {}
@@ -417,7 +439,8 @@ func (t *fakeT) Log(args ...interface{}) {
 }
 
 func (t *fakeT) FailNow() {
-	t.Fatal("failed")
+	t.failed = true
+	panic(errAbort)
 }
 
 func (t *fakeT) Run(name string, f func(T)) {

More details

Full run details

Historical runs