[botanist] Reland "[botanist] Refactor"

This is a reland of 163d0616b0422c582e8cd1c821a1c9e1f43ccb50

Original change's description:
> [botanist] Refactor
>
> Botanist has grown unwielidy over the past ~year and so this change
> returns it to a more manageable and scalable state. Much of the weight
> loss is due to the removal of log support in the multi-device case, which -
> interestingly - has not been used at all or relied upon since its introduction!
> (Indeed, we are creating files (with %d suffixes that we have up front
> not registered as task outputs, and so they never get shipped back to
> the recipe).)
>
> Also removed was botanist's care for not having streamed empty log
> content. This seems like something that should more reasonably be
> checked in the context of a recipe rather than something botanist should
> intimately care about.
>
> Tested: botanist is extensively integration tested being the tool that
> CQ itself consumes from source to run tests and manage device
> interaction. Also, this change can be viewed as bootstrapping
> testability for a more streamlined botanist codebase.
>
> Change-Id: I5809ec79e745339a4b970f8a109e2bb47fe2eef9

Change-Id: I02820760df551365ca64800b9bb73c52d0bc9823
diff --git a/tools/botanist/BUILD.gn b/tools/botanist/BUILD.gn
index e6026d8..1ac8747 100644
--- a/tools/botanist/BUILD.gn
+++ b/tools/botanist/BUILD.gn
@@ -54,11 +54,6 @@
   deps = [ ":main" ]
 }
 
-go_test("botanist_cmd_tests") {
-  gopackages = [ "go.fuchsia.dev/fuchsia/tools/botanist/cmd" ]
-  deps = [ ":main" ]
-}
-
 go_test("botanist_lib_tests") {
   gopackages = [ "go.fuchsia.dev/fuchsia/tools/botanist/lib" ]
   deps = [ ":botanist_lib" ]
@@ -73,7 +68,6 @@
   testonly = true
 
   deps = [
-    ":botanist_cmd_tests",
     ":botanist_lib_tests",
     ":botanist_target_tests",
   ]
diff --git a/tools/botanist/cmd/run.go b/tools/botanist/cmd/run.go
index 42a2711..eb54c46 100644
--- a/tools/botanist/cmd/run.go
+++ b/tools/botanist/cmd/run.go
@@ -12,7 +12,6 @@
 	"io/ioutil"
 	"net"
 	"os"
-	"path/filepath"
 	"sync"
 	"time"
 
@@ -46,16 +45,16 @@
 	SSHKey() string
 
 	// Start starts the target.
-	Start(ctx context.Context, images []bootserver.Image, args []string) error
+	Start(context.Context, []bootserver.Image, []string) error
 
 	// Restart restarts the target.
-	Restart(ctx context.Context) error
+	Restart(context.Context) error
 
 	// Stop stops the target.
-	Stop(ctx context.Context) error
+	Stop(context.Context) error
 
 	// Wait waits for the target to finish running.
-	Wait(ctx context.Context) error
+	Wait(context.Context) error
 }
 
 // RunCommand is a Command implementation for booting a device and running a
@@ -76,12 +75,6 @@
 	// Timeout is the duration allowed for the command to finish execution.
 	timeout time.Duration
 
-	// CmdStdout is the file to which the command's stdout will be redirected.
-	cmdStdout string
-
-	// CmdStderr is the file to which the command's stderr will be redirected.
-	cmdStderr string
-
 	// SysloggerFile, if nonempty, is the file to where the system's logs will be written.
 	syslogFile string
 
@@ -121,8 +114,6 @@
 	f.BoolVar(&r.netboot, "netboot", false, "if set, botanist will not pave; but will netboot instead")
 	f.Var(&r.zirconArgs, "zircon-args", "kernel command-line arguments")
 	f.DurationVar(&r.timeout, "timeout", 10*time.Minute, "duration allowed for the command to finish execution.")
-	f.StringVar(&r.cmdStdout, "stdout", "", "file to redirect the command's stdout into; if unspecified, it will be redirected to the process' stdout")
-	f.StringVar(&r.cmdStderr, "stderr", "", "file to redirect the command's stderr into; if unspecified, it will be redirected to the process' stderr")
 	f.StringVar(&r.syslogFile, "syslog", "", "file to write the systems logs to")
 	f.StringVar(&r.sshKey, "ssh", "", "file containing a private SSH user key; if not provided, a private key will be generated.")
 	f.StringVar(&r.serialLogFile, "serial-log", "", "file to write the serial logs to.")
@@ -134,260 +125,6 @@
 	f.StringVar(&r.blobURL, "blobs", defaultBlobURL, "URL at which to serve a package repository's blobs")
 }
 
-func (r *RunCommand) runCmd(ctx context.Context, args []string, t Target) error {
-	nodename := t.Nodename()
-	ip, err := t.IPv4Addr()
-	if err == nil {
-		logger.Infof(ctx, "IPv4 address of %s found: %s", nodename, ip)
-	} else {
-		logger.Errorf(ctx, "could not resolve IPv4 address of %s: %v", nodename, err)
-	}
-
-	env := append(
-		os.Environ(),
-		fmt.Sprintf("FUCHSIA_NODENAME=%s", nodename),
-		fmt.Sprintf("FUCHSIA_IPV4_ADDR=%v", ip),
-		fmt.Sprintf("FUCHSIA_SSH_KEY=%s", t.SSHKey()),
-	)
-
-	ctx, cancel := context.WithTimeout(ctx, r.timeout)
-	defer cancel()
-
-	stdout := os.Stdout
-	if r.cmdStdout != "" {
-		f, err := os.Create(r.cmdStdout)
-		if err != nil {
-			return err
-		}
-		defer f.Close()
-		stdout = f
-	}
-	stderr := os.Stderr
-	if r.cmdStderr != "" {
-		f, err := os.Create(r.cmdStderr)
-		if err != nil {
-			return err
-		}
-		defer f.Close()
-		stderr = f
-	}
-
-	runner := runner.SubprocessRunner{
-		Env: env,
-	}
-	if err := runner.Run(ctx, args, stdout, stderr); err != nil {
-		if ctx.Err() != nil {
-			return fmt.Errorf("command timed out after %v", r.timeout)
-		}
-		return err
-	}
-	return nil
-}
-
-func getIndexedFilename(filename string, index int) string {
-	ext := filepath.Ext(filename)
-	name := filename[:len(filename)-len(ext)]
-	return fmt.Sprintf("%s-%d%s", name, index, ext)
-}
-
-type targetSetup struct {
-	targets    []Target
-	syslogs    []*logWriter
-	serialLogs []*logWriter
-	err        error
-}
-
-type logWriter struct {
-	name   string
-	file   io.ReadWriteCloser
-	target Target
-}
-
-func (r *RunCommand) setupTargets(ctx context.Context, imgs []bootserver.Image, targets []Target) *targetSetup {
-	var syslogs, serialLogs []*logWriter
-	errs := make(chan error, len(targets))
-	var wg sync.WaitGroup
-	var setupErr error
-
-	for i, t := range targets {
-		var s *os.File
-		var err error
-		if r.syslogFile != "" {
-			syslogFile := r.syslogFile
-			if len(targets) > 1 {
-				syslogFile = getIndexedFilename(r.syslogFile, i)
-			}
-			s, err = os.Create(syslogFile)
-			if err != nil {
-				setupErr = err
-				break
-			}
-			syslogs = append(syslogs, &logWriter{
-				name:   syslogFile,
-				file:   s,
-				target: t,
-			})
-		}
-
-		zirconArgs := r.zirconArgs
-		if t.Serial() != nil {
-			if r.serialLogFile != "" {
-				serialLogFile := r.serialLogFile
-				if len(targets) > 1 {
-					serialLogFile = getIndexedFilename(r.serialLogFile, i)
-				}
-				serialLog, err := os.Create(serialLogFile)
-				if err != nil {
-					setupErr = err
-					break
-				}
-				serialLogs = append(serialLogs, &logWriter{
-					name:   serialLogFile,
-					file:   serialLog,
-					target: t,
-				})
-
-				// Here we invoke the `dlog` command over serial to tail the existing log buffer into the
-				// output file.  This should give us everything since Zedboot boot, and new messages should
-				// be written to directly to the serial port without needing to tail with `dlog -f`.
-				if _, err = io.WriteString(t.Serial(), "\ndlog\n"); err != nil {
-					logger.Errorf(ctx, "failed to tail zedboot dlog: %v", err)
-				}
-
-				go func(t Target, serialLog io.ReadWriteCloser) {
-					for {
-						_, err := io.Copy(serialLog, t.Serial())
-						if err != nil && err != io.EOF {
-							logger.Errorf(ctx, "failed to write serial log: %v", err)
-							return
-						}
-					}
-				}(t, serialLog)
-				zirconArgs = append(zirconArgs, "kernel.bypass-debuglog=true")
-			}
-			// Modify the zirconArgs passed to the kernel on boot to enable serial on x64.
-			// arm64 devices should already be enabling kernel.serial at compile time.
-			zirconArgs = append(zirconArgs, "kernel.serial=legacy")
-		}
-
-		wg.Add(1)
-		go func(t Target, s *os.File, zirconArgs []string) {
-			defer wg.Done()
-			if err := t.Start(ctx, imgs, zirconArgs); err != nil {
-				errs <- err
-				return
-			}
-			nodename := t.Nodename()
-
-			if r.syslogFile != "" && s == nil {
-				errs <- fmt.Errorf("syslog is nil.")
-				return
-			}
-
-			// If having paved, SSH in and stream syslogs back to a file sink.
-			if !r.netboot && s != nil {
-				p, err := ioutil.ReadFile(t.SSHKey())
-				if err != nil {
-					errs <- err
-					return
-				}
-				config, err := sshutil.DefaultSSHConfig(p)
-				if err != nil {
-					errs <- err
-					return
-				}
-				client, err := sshutil.ConnectToNode(ctx, nodename, config)
-				if err != nil {
-					errs <- err
-					return
-				}
-				syslogger := syslog.NewSyslogger(client, config)
-				go func() {
-					defer syslogger.Close()
-					if err := syslogger.Stream(ctx, s, false); err != nil {
-						errs <- err
-					}
-				}()
-			}
-		}(t, s, zirconArgs)
-	}
-	// Wait for all targets to finish starting.
-	wg.Wait()
-	// We can close the channel on the receiver end since we wait for all target goroutines to finish.
-	close(errs)
-	err, ok := <-errs
-	if ok {
-		setupErr = err
-	}
-	return &targetSetup{
-		targets:    targets,
-		syslogs:    syslogs,
-		serialLogs: serialLogs,
-		err:        setupErr,
-	}
-}
-
-func (r *RunCommand) runCmdWithTargets(ctx context.Context, targetSetup *targetSetup, args []string) error {
-	for _, log := range targetSetup.syslogs {
-		defer log.file.Close()
-	}
-	for _, log := range targetSetup.serialLogs {
-		defer func(log *logWriter) {
-			if err := log.target.Serial().Close(); err == nil {
-				logger.Errorf(ctx, "serial output not closed yet.")
-			}
-			log.file.Close()
-		}(log)
-	}
-	for _, t := range targetSetup.targets {
-		defer func(t Target) {
-			logger.Debugf(ctx, "stopping or rebooting the node %q\n", t.Nodename())
-			if err := t.Stop(ctx); err == target.ErrUnimplemented {
-				t.Restart(ctx)
-			}
-		}(t)
-	}
-
-	if targetSetup.err != nil {
-		return targetSetup.err
-	}
-
-	errs := make(chan error)
-
-	go func() {
-		// Target doesn't matter for multi-device host tests. Just use first one.
-		errs <- r.runCmd(ctx, args, targetSetup.targets[0])
-	}()
-
-	for _, t := range targetSetup.targets {
-		go func(t Target) {
-			if err := t.Wait(ctx); err != nil && err != target.ErrUnimplemented {
-				errs <- err
-			}
-		}(t)
-	}
-
-	select {
-	case err := <-errs:
-		return err
-	case <-ctx.Done():
-	}
-	return nil
-}
-
-func checkEmptyLogs(ctx context.Context, logs []*logWriter) error {
-	for _, log := range logs {
-		b, err := ioutil.ReadFile(log.name)
-		if err != nil {
-			return err
-		}
-		if len(b) == 0 {
-			return fmt.Errorf("log is empty.")
-		}
-	}
-	return nil
-}
-
 func (r *RunCommand) execute(ctx context.Context, args []string) error {
 	var bootMode bootserver.Mode
 	if r.netboot {
@@ -417,21 +154,159 @@
 
 	var targets []Target
 	for _, obj := range objs {
-		t, err := DeriveTarget(ctx, obj, opts)
+		t, err := deriveTarget(ctx, obj, opts)
 		if err != nil {
 			return err
 		}
 		targets = append(targets, t)
 	}
+	if len(targets) == 0 {
+		return fmt.Errorf("no targets found")
+	}
+
+	// This is the primary target that a command will be run against and that
+	// logs will be streamed from.
+	t0 := targets[0]
+
+	errs := make(chan error)
+
+	serial := t0.Serial()
+	if serial != nil && r.serialLogFile != "" {
+		// Modify the zirconArgs passed to the kernel on boot to enable serial on x64.
+		// arm64 devices should already be enabling kernel.serial at compile time.
+		r.zirconArgs = append(r.zirconArgs, "kernel.serial=legacy")
+		// Force serial output to the console instead of buffering it.
+		r.zirconArgs = append(r.zirconArgs, "kernel.bypass-debuglog=true")
+
+		serialLog, err := os.Create(r.serialLogFile)
+		if err != nil {
+			return err
+		}
+		defer serialLog.Close()
+		go func() {
+			if _, err := io.Copy(serialLog, serial); err != nil {
+				errs <- fmt.Errorf("failed to write serial log: %v", err)
+			}
+		}()
+	}
 
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
-	targetSetup := r.setupTargets(ctx, imgs, targets)
-	if err := r.runCmdWithTargets(ctx, targetSetup, args); err != nil {
-		return err
+	// Defer asynchronously restarts of each target.
+	defer func() {
+		var wg sync.WaitGroup
+		for _, t := range targets {
+			wg.Add(1)
+			go func(t Target) {
+				defer wg.Done()
+				logger.Debugf(ctx, "stopping or rebooting the node %q\n", t.Nodename())
+				if err := t.Stop(ctx); err == target.ErrUnimplemented {
+					t.Restart(ctx)
+				}
+			}(t)
+		}
+		wg.Wait()
+	}()
+
+	// We wait until targets have started before running the subcommand against the zeroth one.
+	var wg sync.WaitGroup
+	for _, t := range targets {
+		wg.Add(1)
+		go func(t Target) {
+			if err := t.Start(ctx, imgs, r.zirconArgs); err != nil {
+				wg.Done()
+				errs <- err
+				return
+			}
+			wg.Done()
+			if err := t.Wait(ctx); err != nil && err != target.ErrUnimplemented {
+				errs <- err
+			}
+		}(t)
 	}
-	return checkEmptyLogs(ctx, append(targetSetup.syslogs, targetSetup.serialLogs...))
+	go func() {
+		wg.Wait()
+		errs <- r.runAgainstTarget(ctx, t0, args)
+	}()
+
+	select {
+	case err := <-errs:
+		return err
+	case <-ctx.Done():
+	}
+	return nil
+}
+
+func (r *RunCommand) runAgainstTarget(ctx context.Context, t Target, args []string) error {
+	subprocessEnv := map[string]string{
+		"FUCHSIA_NODENAME": t.Nodename(),
+	}
+
+	// If |netboot| is true, then we assume that fuchsia is not provisioned
+	// with a netstack; in this case, do not try to establish a connection.
+	if !r.netboot {
+		p, err := ioutil.ReadFile(t.SSHKey())
+		if err != nil {
+			return err
+		}
+		config, err := sshutil.DefaultSSHConfig(p)
+		if err != nil {
+			return err
+		}
+		client, err := sshutil.ConnectToNode(ctx, t.Nodename(), config)
+		if err != nil {
+			return err
+		}
+		defer client.Close()
+		subprocessEnv["FUCHSIA_SSH_KEY"] = t.SSHKey()
+
+		ip, err := t.IPv4Addr()
+		if err != nil {
+			logger.Errorf(ctx, "could not resolve IPv4 address of %s: %v", t.Nodename(), err)
+		} else if ip != nil {
+			logger.Infof(ctx, "IPv4 address of %s found: %s", t.Nodename(), ip)
+			subprocessEnv["FUCHSIA_IPV4_ADDR"] = ip.String()
+		}
+
+		if r.syslogFile != "" {
+			s, err := os.Create(r.syslogFile)
+			if err != nil {
+				return err
+			}
+			defer s.Close()
+			syslogger := syslog.NewSyslogger(client, config)
+			defer syslogger.Close()
+
+			go func() {
+				err := syslogger.Stream(ctx, s, false)
+				// TODO(fxbug.dev/43518): when 1.13 is available, spell this as
+				// `err != nil && errors.Is(err, context.Canceled) && errors.Is(err, context.DeadlineExceeded)`
+				if err != nil && ctx.Err() == nil {
+					logger.Errorf(ctx, "syslog streaming interrupted: %v", err)
+				}
+			}()
+		}
+	}
+
+	// Run the provided command against t0, adding |subprocessEnv| into
+	// its environment.
+	environ := os.Environ()
+	for k, v := range subprocessEnv {
+		environ = append(environ, fmt.Sprintf("%s=%s", k, v))
+	}
+	runner := runner.SubprocessRunner{
+		Env: environ,
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, r.timeout)
+	defer cancel()
+
+	err := runner.Run(ctx, args, os.Stdout, os.Stderr)
+	if ctx.Err() == context.DeadlineExceeded {
+		return fmt.Errorf("command timed out after %v", r.timeout)
+	}
+	return err
 }
 
 func (r *RunCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
@@ -446,7 +321,7 @@
 	return subcommands.ExitSuccess
 }
 
-func DeriveTarget(ctx context.Context, obj []byte, opts target.Options) (Target, error) {
+func deriveTarget(ctx context.Context, obj []byte, opts target.Options) (Target, error) {
 	type typed struct {
 		Type string `json:"type"`
 	}
diff --git a/tools/botanist/cmd/run_test.go b/tools/botanist/cmd/run_test.go
deleted file mode 100644
index 802945d..0000000
--- a/tools/botanist/cmd/run_test.go
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright 2019 The Fuchsia Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-package main
-
-import (
-	"context"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-	"time"
-
-	"go.fuchsia.dev/fuchsia/tools/bootserver/lib"
-	"go.fuchsia.dev/fuchsia/tools/botanist/target"
-	"go.fuchsia.dev/fuchsia/tools/lib/color"
-	"go.fuchsia.dev/fuchsia/tools/lib/logger"
-)
-
-func TestExecute(t *testing.T) {
-	t.Run("close logs after cmd finishes", func(t *testing.T) {
-		tmpDir, err := ioutil.TempDir("", fmt.Sprintf("test-data"))
-		if err != nil {
-			t.Fatalf("failed to create temp dir: %v", err)
-		}
-		defer os.RemoveAll(tmpDir)
-		syslogPath := filepath.Join(tmpDir, "syslog.txt")
-		serialLogPath := filepath.Join(tmpDir, "serial.txt")
-
-		// create empty script to pass as cmd to run.
-		scriptPath := filepath.Join(tmpDir, "test_script.sh")
-		if err = ioutil.WriteFile(scriptPath, []byte("#!/bin/bash\n# don't do anything."), 0755); err != nil {
-			t.Fatalf("Failed to write to script: %s", err)
-		}
-
-		cmd := &RunCommand{
-			syslogFile:    syslogPath,
-			netboot:       true,
-			timeout:       time.Second * 5,
-			serialLogFile: serialLogPath,
-		}
-		loggerOutFile := filepath.Join(tmpDir, "logger_out.txt")
-		loggerOut, err := os.Create(loggerOutFile)
-		if err != nil {
-			t.Fatalf("failed to create log file: %v", err)
-		}
-
-		serialName := filepath.Join(tmpDir, "real_serial.txt")
-		serialWriter, err := os.Create(serialName)
-		if err != nil {
-			t.Fatalf("failed to create mock serial writer: %v", err)
-		}
-		defer serialWriter.Close()
-		testLogger := logger.NewLogger(logger.DebugLevel, color.NewColor(color.ColorAuto), loggerOut, loggerOut, "botanist ")
-		ctx := logger.WithLogger(context.Background(), testLogger)
-		var targets []Target
-		for i := 0; i < 2; i++ {
-			var serial io.ReadWriteCloser
-			if i == 0 {
-				serial = serialWriter
-			}
-			target, err := target.NewMockTarget(ctx, fmt.Sprintf("mock-target-%d", i+1), serial)
-			if err != nil {
-				t.Fatalf("failed to create new target: %v", err)
-			}
-			targets = append(targets, target)
-		}
-
-		// cmd.execute() calls setupTargets() followed by runCmdWithTargets().
-		// The logs should be open for writing to when runCmdWithTargets()
-		// is called and closed after it finishes.
-		targetSetup := cmd.setupTargets(ctx, []bootserver.Image{}, targets)
-		for _, log := range targetSetup.syslogs {
-			if _, err := io.WriteString(log.file, "File is open!"); err != nil {
-				t.Fatalf("File is not open: %v", err)
-			}
-		}
-		if err = cmd.runCmdWithTargets(ctx, targetSetup, []string{scriptPath}); err != nil {
-			t.Fatalf("Execute failed with error: %v", err)
-		}
-		for _, log := range targetSetup.syslogs {
-			if _, err := io.WriteString(log.file, "File is closed!"); err == nil {
-				t.Fatalf("File is open: %v", err)
-			}
-		}
-
-		loggerOut.Close()
-		log, err := ioutil.ReadFile(loggerOutFile)
-		if err != nil {
-			t.Fatalf("%v", err)
-		}
-		if strings.Contains(string(log), "serial output not closed yet") {
-			t.Fatalf("Failed to close syslog/serial logs after stopping the target.")
-		}
-	})
-
-	t.Run("fail for empty logs", func(t *testing.T) {
-		tmpDir, err := ioutil.TempDir("", fmt.Sprintf("test-data"))
-		if err != nil {
-			t.Fatalf("failed to create temp dir: %v", err)
-		}
-		defer os.RemoveAll(tmpDir)
-		syslogPath := filepath.Join(tmpDir, "syslog.txt")
-
-		// create empty script to pass as cmd to run.
-		scriptPath := filepath.Join(tmpDir, "test_script.sh")
-		if err = ioutil.WriteFile(scriptPath, []byte("#!/bin/bash\n# don't do anything."), 0755); err != nil {
-			t.Fatalf("Failed to write to script: %s", err)
-		}
-
-		cmd := &RunCommand{
-			syslogFile: syslogPath,
-			netboot:    true,
-			timeout:    time.Second * 5,
-		}
-		ctx := context.Background()
-		target, err := target.NewMockTarget(ctx, "mock-target", nil)
-		if err != nil {
-			t.Fatalf("failed to create new target: %v", err)
-		}
-		targets := []Target{target}
-
-		targetSetup := cmd.setupTargets(ctx, []bootserver.Image{}, targets)
-		if err = cmd.runCmdWithTargets(ctx, targetSetup, []string{scriptPath}); err != nil {
-			t.Fatalf("Execute failed with error: %v", err)
-		}
-		// Logs should be empty.
-		if err = checkEmptyLogs(ctx, append(targetSetup.syslogs, targetSetup.serialLogs...)); err == nil {
-			t.Fatalf("Did not fail for empty logs")
-		}
-	})
-}
diff --git a/tools/botanist/cmd/zedboot.go b/tools/botanist/cmd/zedboot.go
index b127f69..82a8b68 100644
--- a/tools/botanist/cmd/zedboot.go
+++ b/tools/botanist/cmd/zedboot.go
@@ -11,6 +11,7 @@
 	"io/ioutil"
 	"os"
 	"strings"
+	"sync"
 	"time"
 
 	"go.fuchsia.dev/fuchsia/tools/bootserver/lib"
@@ -120,58 +121,59 @@
 	defer cancel()
 	errs := make(chan error)
 
-	var serialLogs []*logWriter
-	for i, device := range devices {
-		if device.Serial() != nil {
-			if cmd.serialLogFile != "" {
-				serialLogFile := cmd.serialLogFile
-				if len(devices) > 1 {
-					serialLogFile = getIndexedFilename(cmd.serialLogFile, i)
-				}
-				serialLog, err := os.Create(serialLogFile)
-				if err != nil {
-					return err
-				}
-				serialLogs = append(serialLogs, &logWriter{
-					name: serialLogFile,
-					file: serialLog})
+	if serial := devices[0].Serial(); serial != nil && cmd.serialLogFile != "" {
+		defer serial.Close()
 
-				// Here we invoke the `dlog` command over serial to tail the existing log buffer into the
-				// output file.  This should give us everything since Zedboot boot, and new messages should
-				// be written to directly to the serial port without needing to tail with `dlog -f`.
-				if _, err = io.WriteString(device.Serial(), "\ndlog\n"); err != nil {
-					logger.Errorf(ctx, "failed to tail zedboot dlog: %v", err)
-				}
+		// Modify the zirconArgs passed to the kernel on boot to enable serial on x64.
+		// arm64 devices should already be enabling kernel.serial at compile time.
+		cmdlineArgs = append(cmdlineArgs, "kernel.serial=legacy")
+		// Force serial output to the console instead of buffering it.
+		cmdlineArgs = append(cmdlineArgs, "kernel.bypass-debuglog=true")
 
-				go func(device *target.DeviceTarget) {
-					for {
-						_, err := io.Copy(serialLog, device.Serial())
-						if err != nil && err != io.EOF {
-							logger.Errorf(ctx, "failed to write serial log: %v", err)
-							return
-						}
-					}
-				}(device)
-				cmdlineArgs = append(cmdlineArgs, "kernel.bypass-debuglog=true")
-			}
-			// Modify the cmdlineArgs passed to the kernel on boot to enable serial on x64.
-			// arm64 devices should already be enabling kernel.serial at compile time.
-			cmdlineArgs = append(cmdlineArgs, "kernel.serial=legacy")
+		serialLog, err := os.Create(cmd.serialLogFile)
+		if err != nil {
+			return err
 		}
+		defer serialLog.Close()
+		// Here we invoke the `dlog` command over serial to tail the existing log buffer into the
+		// output file.  This should give us everything since Zedboot boot, and new messages should
+		// be written to directly to the serial port without needing to tail with `dlog -f`.
+		if _, err = io.WriteString(serial, "\ndlog\n"); err != nil {
+			return fmt.Errorf("failed to tail zedboot dlog: %v", err)
+		}
+		go func() {
+			if _, err := io.Copy(serialLog, serial); err != nil {
+				errs <- fmt.Errorf("failed to write serial log: %v", err)
+			}
+		}()
+	}
 
+	// Defer asynchronously restarts of each device.
+	defer func() {
+		var wg sync.WaitGroup
+		for _, device := range devices {
+			wg.Add(1)
+			go func(device *target.DeviceTarget) {
+				defer wg.Done()
+				device.Restart(ctx)
+			}(device)
+		}
+		wg.Wait()
+	}()
+
+	var wg sync.WaitGroup
+	for _, device := range devices {
+		wg.Add(1)
 		go func(device *target.DeviceTarget) {
+			defer wg.Done()
 			if err := device.Start(ctx, imgs, cmdlineArgs); err != nil {
 				errs <- err
 			}
 		}(device)
 	}
-	for _, log := range serialLogs {
-		defer log.file.Close()
-	}
-	for _, device := range devices {
-		defer device.Restart(ctx)
-	}
+
 	go func() {
+		wg.Wait()
 		// We execute tests here against the 0th device, there may be N devices
 		// in the test bed but all other devices are driven by the tests not
 		// the test runner.
@@ -182,9 +184,8 @@
 	case err := <-errs:
 		return err
 	case <-ctx.Done():
+		return nil
 	}
-
-	return checkEmptyLogs(ctx, serialLogs)
 }
 
 func (cmd *ZedbootCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
diff --git a/tools/botanist/target/device.go b/tools/botanist/target/device.go
index 78ef799..c69261f 100644
--- a/tools/botanist/target/device.go
+++ b/tools/botanist/target/device.go
@@ -16,7 +16,6 @@
 
 	"go.fuchsia.dev/fuchsia/tools/bootserver/lib"
 	"go.fuchsia.dev/fuchsia/tools/botanist/lib"
-	"go.fuchsia.dev/fuchsia/tools/lib/logger"
 	"go.fuchsia.dev/fuchsia/tools/net/netboot"
 	"go.fuchsia.dev/fuchsia/tools/net/netutil"
 	"go.fuchsia.dev/fuchsia/tools/net/tftp"
@@ -28,6 +27,9 @@
 const (
 	// The duration we allow for the netstack to come up when booting.
 	netstackTimeout = 90 * time.Second
+
+	// Command to dump the zircon debug log over serial.
+	dlogCmd = "\ndlog\n"
 )
 
 // DeviceConfig contains the static properties of a target device.
@@ -90,10 +92,11 @@
 	if config.Serial != "" {
 		s, err = serial.Open(config.Serial)
 		if err != nil {
-			// TODO(IN-????): This should be returned as an error, but we don't want to fail any
-			// test runs for misconfigured serial until it is actually required to complete certain
-			// tasks.
-			logger.Errorf(ctx, "unable to open %s: %v", config.Serial, err)
+			return nil, fmt.Errorf("unable to open %s: %v", config.Serial, err)
+		}
+		// Dump the existing serial debug log buffer.
+		if _, err := io.WriteString(s, dlogCmd); err != nil {
+			return nil, fmt.Errorf("failed to tail serial logs: %v", err)
 		}
 	}
 	addr, err := netutil.GetNodeAddress(ctx, config.Network.Nodename, false)
@@ -186,12 +189,12 @@
 }
 
 // Stop stops the device.
-func (t *DeviceTarget) Stop(ctx context.Context) error {
+func (t *DeviceTarget) Stop(context.Context) error {
 	return ErrUnimplemented
 }
 
 // Wait waits for the device target to stop.
-func (t *DeviceTarget) Wait(ctx context.Context) error {
+func (t *DeviceTarget) Wait(context.Context) error {
 	return ErrUnimplemented
 }