diff --git a/cli/clibase/cmd.go b/cli/clibase/cmd.go
index dff1e10a63b9b..0d4258d46023f 100644
--- a/cli/clibase/cmd.go
+++ b/cli/clibase/cmd.go
@@ -448,6 +448,19 @@ func (inv *Invocation) Run() (err error) {
panic(err)
}
}()
+ // We close Stdin to prevent deadlocks, e.g. when the command
+ // has ended but an io.Copy is still reading from Stdin.
+ defer func() {
+ if inv.Stdin == nil {
+ return
+ }
+ rc, ok := inv.Stdin.(io.ReadCloser)
+ if !ok {
+ return
+ }
+ e := rc.Close()
+ err = errors.Join(err, e)
+ }()
err = inv.run(&runState{
allArgs: inv.Args,
})
diff --git a/cli/ssh.go b/cli/ssh.go
index 05573b3e2afae..51226cde6b93d 100644
--- a/cli/ssh.go
+++ b/cli/ssh.go
@@ -10,9 +10,9 @@ import (
"net/url"
"os"
"os/exec"
- "path"
"path/filepath"
"strings"
+ "sync"
"time"
"github.com/gen2brain/beeep"
@@ -52,8 +52,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
wsPollInterval time.Duration
waitEnum string
noWait bool
- logDir string
- logToFile bool
+ logDirPath string
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
@@ -76,24 +75,45 @@ func (r *RootCmd) ssh() *clibase.Cmd {
logger.Error(ctx, "command exit", slog.Error(retErr))
}
}()
- if logToFile {
- // we need a way to ensure different ssh invocations don't clobber
- // each other's logs. Date-time strings will likely have collisions
- // in unit tests and/or scripts unless we extend precision out to
- // sub-millisecond, which seems unwieldy. A simple 5-character random
- // string will do it, since the operating system already tracks
- // dates and times for file IO.
- qual, err := cryptorand.String(5)
+
+ // This WaitGroup solves for a race condition where we were logging
+ // while closing the log file in a defer. It probably solves
+ // others too.
+ var wg sync.WaitGroup
+ wg.Add(1)
+ defer wg.Done()
+
+ if logDirPath != "" {
+ nonce, err := cryptorand.StringCharset(cryptorand.Lower, 5)
if err != nil {
- return xerrors.Errorf("generate random qualifier: %w", err)
+ return xerrors.Errorf("generate nonce: %w", err)
}
- logPth := path.Join(logDir, fmt.Sprintf("coder-ssh-%s.log", qual))
- logFile, err := os.Create(logPth)
+ logFilePath := filepath.Join(
+ logDirPath,
+ fmt.Sprintf(
+ "coder-ssh-%s-%s.log",
+ // The time portion makes it easier to find the right
+ // log file.
+ time.Now().Format("20060102-150405"),
+ // The nonce prevents collisions, as SSH invocations
+ // frequently happen in parallel.
+ nonce,
+ ),
+ )
+ logFile, err := os.OpenFile(
+ logFilePath,
+ os.O_CREATE|os.O_APPEND|os.O_WRONLY|os.O_EXCL,
+ 0o600,
+ )
if err != nil {
- return xerrors.Errorf("error opening %s for logging: %w", logPth, err)
+ return xerrors.Errorf("error opening %s for logging: %w", logDirPath, err)
}
+ go func() {
+ wg.Wait()
+ logFile.Close()
+ }()
+
logger = slog.Make(sloghuman.Sink(logFile))
- defer logFile.Close()
if r.verbose {
logger = logger.Leveled(slog.LevelDebug)
}
@@ -192,9 +212,18 @@ func (r *RootCmd) ssh() *clibase.Cmd {
return xerrors.Errorf("connect SSH: %w", err)
}
defer rawSSH.Close()
- go watchAndClose(ctx, rawSSH.Close, logger, client, workspace)
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ watchAndClose(ctx, func() error {
+ return rawSSH.Close()
+ }, logger, client, workspace)
+ }()
+
+ wg.Add(1)
go func() {
+ defer wg.Done()
// Ensure stdout copy closes incase stdin is closed
// unexpectedly. Typically we wouldn't worry about
// this since OpenSSH should kill the proxy command.
@@ -227,19 +256,24 @@ func (r *RootCmd) ssh() *clibase.Cmd {
return xerrors.Errorf("ssh session: %w", err)
}
defer sshSession.Close()
- go watchAndClose(
- ctx,
- func() error {
- err := sshSession.Close()
- logger.Debug(ctx, "session close", slog.Error(err))
- err = sshClient.Close()
- logger.Debug(ctx, "client close", slog.Error(err))
- return nil
- },
- logger,
- client,
- workspace,
- )
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ watchAndClose(
+ ctx,
+ func() error {
+ err := sshSession.Close()
+ logger.Debug(ctx, "session close", slog.Error(err))
+ err = sshClient.Close()
+ logger.Debug(ctx, "client close", slog.Error(err))
+ return nil
+ },
+ logger,
+ client,
+ workspace,
+ )
+ }()
if identityAgent == "" {
identityAgent = os.Getenv("SSH_AUTH_SOCK")
@@ -389,18 +423,11 @@ func (r *RootCmd) ssh() *clibase.Cmd {
UseInstead: []clibase.Option{waitOption},
},
{
- Flag: "log-dir",
- Default: os.TempDir(),
- Description: "Specify the location for the log files.",
- Env: "CODER_SSH_LOG_DIR",
- Value: clibase.StringOf(&logDir),
- },
- {
- Flag: "log-to-file",
+ Flag: "log-dir",
+ Description: "Specify the directory containing SSH diagnostic log files.",
+ Env: "CODER_SSH_LOG_DIR",
FlagShorthand: "l",
- Env: "CODER_SSH_LOG_TO_FILE",
- Description: "Enable diagnostic logging to file.",
- Value: clibase.BoolOf(&logToFile),
+ Value: clibase.StringOf(&logDirPath),
},
}
return cmd
diff --git a/cli/ssh_test.go b/cli/ssh_test.go
index 88e64d6fe48d7..b38c02bd371b0 100644
--- a/cli/ssh_test.go
+++ b/cli/ssh_test.go
@@ -261,7 +261,7 @@ func TestSSH(t *testing.T) {
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
_, _ = tGoContext(t, func(ctx context.Context) {
// Run this async so the SSH command has to wait for
- // the build and agent to connect!
+ // the build and agent to connect.
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(agentToken)
agentCloser := agent.New(agent.Options{
@@ -411,20 +411,14 @@ func TestSSH(t *testing.T) {
t.Run("FileLogging", func(t *testing.T) {
t.Parallel()
- dir := t.TempDir()
+ logDir := t.TempDir()
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
- inv, root := clitest.New(t, "ssh", workspace.Name, "-l", "--log-dir", dir)
+ inv, root := clitest.New(t, "ssh", "-l", logDir, workspace.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
+ w := clitest.StartWithWaiter(t, inv)
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- cmdDone := tGo(t, func() {
- err := inv.WithContext(ctx).Run()
- assert.NoError(t, err)
- })
pty.ExpectMatch("Waiting")
agentClient := agentsdk.New(client.URL)
@@ -439,17 +433,11 @@ func TestSSH(t *testing.T) {
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
pty.WriteLine("exit")
- <-cmdDone
+ w.RequireSuccess()
- entries, err := os.ReadDir(dir)
+ ents, err := os.ReadDir(logDir)
require.NoError(t, err)
- for _, e := range entries {
- t.Logf("logdir entry: %s", e.Name())
- if strings.HasPrefix(e.Name(), "coder-ssh") {
- return
- }
- }
- t.Fatal("failed to find ssh logfile")
+ require.Len(t, ents, 1, "expected one file in logdir %s", logDir)
})
}
diff --git a/cli/testdata/coder_ssh_--help.golden b/cli/testdata/coder_ssh_--help.golden
index 890c08e1e57c7..4824f1c0dff95 100644
--- a/cli/testdata/coder_ssh_--help.golden
+++ b/cli/testdata/coder_ssh_--help.golden
@@ -18,11 +18,8 @@ Start a shell into a workspace
Specifies which identity agent to use (overrides $SSH_AUTH_SOCK),
forward agent must also be enabled.
- --log-dir string, $CODER_SSH_LOG_DIR (default: /tmp)
- Specify the location for the log files.
-
- -l, --log-to-file bool, $CODER_SSH_LOG_TO_FILE
- Enable diagnostic logging to file.
+ -l, --log-dir string, $CODER_SSH_LOG_DIR
+ Specify the directory containing SSH diagnostic log files.
--no-wait bool, $CODER_SSH_NO_WAIT
Enter workspace immediately after the agent has connected. This is the
diff --git a/docs/cli/ssh.md b/docs/cli/ssh.md
index e6f03f6c326ae..bcdaae5a82c71 100644
--- a/docs/cli/ssh.md
+++ b/docs/cli/ssh.md
@@ -39,24 +39,14 @@ Specifies whether to forward the GPG agent. Unsupported on Windows workspaces, b
Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled.
-### --log-dir
+### -l, --log-dir
| | |
| ----------- | ------------------------------- |
| Type | string
|
| Environment | $CODER_SSH_LOG_DIR
|
-| Default | /tmp
|
-Specify the location for the log files.
-
-### -l, --log-to-file
-
-| | |
-| ----------- | ----------------------------------- |
-| Type | bool
|
-| Environment | $CODER_SSH_LOG_TO_FILE
|
-
-Enable diagnostic logging to file.
+Specify the directory containing SSH diagnostic log files.
### --no-wait