From 89b6610bac18aceccc8eed3dc12194ba595534b1 Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 18:13:41 +0200 Subject: [PATCH] shell: report a truncated -c command line instead of hanging on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote shell caps how long a single command line may be, and past that cap it truncates the line and says nothing at all: the command still runs, but what falls off the end is dwshell's RC sentinel, so -c waited out its whole --timeout — or, with no --timeout, forever — for a marker that could never arrive. This is not specific to Windows. cmd.exe cuts at 8190 characters, but a *nix remote truncates too, depending on the user's shell: bash and zsh read the prompt line in raw mode and take a megabyte, while sh, dash and ash read in canonical mode, where the tty line discipline cuts at 4094. Measured under dash on a live agent: 4094 passes, 4095 does not. Which shell the remote user has is not knowable in advance, and guessing it would be the wrong side of the trade — a wrong guess denies work the remote would have run. So detect instead of predict: type a short probe line after the command. A shell reads it only once it has read and run the command line, so its marker cannot come back before that command's RC sentinel unless the line was cut short. Verified under dash and on Windows: with the line truncated the RC never appears and the probe still does. Like the RC sentinel, the probe marker is assembled by the remote, so neither the PTY echoing the line back nor a command printing its own stdin can be taken for it. Cost is one 55-byte message per -c run. Detection cannot prevent the partial execution that already happened; it replaces a silent hang with a clear error, and the error names no character count, since every such figure belongs to one shell only. Verified live: on Windows 8190 still runs and 8191 reports in 2 s rather than timing out; exit codes, a 1 MB bash command and 60 KB byte-exact round-trips all unaffected, and a stdin-reading command does not false-positive. Fixes #3. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- README.md | 8 +++++--- internal/app/shell/run.go | 34 +++++++++++++++++++++++++++++++++ internal/app/shell/run_test.go | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 internal/app/shell/run_test.go diff --git a/README.md b/README.md index 58a4f76..34e6507 100644 --- a/README.md +++ b/README.md @@ -248,9 +248,11 @@ DWSHELL_REMOTE_PASSWORD=… dwshell alice@myhost -c "id" - `-c ` — run a command non-interactively and exit with its code. Long commands are fine — they are split across several protocol messages, the - way typing them would be — but the *remote shell* still applies its own limit: - `cmd.exe` on Windows truncates a command line past 8191 characters, so pass a - bigger script with `dwshell put` and run it by path. + way typing them would be — but the *remote shell* still caps how long a single + command line may be, and the cap differs from shell to shell. A remote that + truncates the line is reported as such instead of leaving you waiting on + output that can never arrive; pass a bigger script with `dwshell put` and run + it by path. - `--own` / `--shared` — resolve `` among owned agents / incoming shares only. - `--term ` — TERM to send to a *nix remote (default: your local `$TERM`). - `--no-term` — do not send a TERM to the remote. diff --git a/internal/app/shell/run.go b/internal/app/shell/run.go index 0727809..ead0e47 100644 --- a/internal/app/shell/run.go +++ b/internal/app/shell/run.go @@ -3,6 +3,7 @@ package shell import ( "bytes" "context" + "errors" "fmt" "regexp" "strconv" @@ -15,6 +16,7 @@ import ( var ( reBegin = regexp.MustCompile(`__DWSH_BEGIN__\r?\n`) reRC = regexp.MustCompile(`__DWSH_RC_(\d+)_END__`) + reTrunc = regexp.MustCompile(`__DWSH_TRUNC_(\d+)_END__`) ) // RunResult is the outcome of a non-interactive command. @@ -38,6 +40,30 @@ func wrapCommand(cmd string, os remote.OS) string { return fmt.Sprintf("echo __DWSH_BEGIN__; ( %s ); echo __DWSH_RC_$?_END__\r", cmd) } +// errTruncated reports a command line the remote shell cut short. How long a +// line a remote accepts is the remote shell's own business and differs between +// shells, so dwshell does not try to predict it — it reports the truncation +// when it happens instead. +var errTruncated = errors.New( + "the remote shell truncated the command line, so it ran a partial command and the exit-code marker was lost; " + + "send a shorter command, or upload it with `dwshell put` and run it by path") + +// probeLine is typed as its own short line right after the command. A shell +// reads it only once it has read and run the command line, so its marker can +// never come back before that command's RC sentinel — unless the remote +// truncated the command line and took the sentinel with it. That makes a lost +// sentinel detectable on any remote, whatever its line limit happens to be. +// +// Like the RC sentinel, the marker is assembled by the remote (`$?` / +// %errorlevel%) so that the PTY echoing the typed line back, or a command that +// reads its stdin and prints it, cannot be mistaken for the marker itself. +func probeLine(os remote.OS) string { + if os == remote.OSWindows { + return "echo __DWSH_TRUNC_%errorlevel%_END__\r" + } + return "echo __DWSH_TRUNC_$?_END__\r" +} + // Run executes a single command non-interactively and returns its output and // exit code. It opens a fresh shell, sends the wrapped command, and reads until // the RC sentinel (or ctx/timeout fires). @@ -57,6 +83,9 @@ func Run(ctx context.Context, sess *session.Session, os remote.OS, command strin if err := sh.Input(wrapCommand(command, os)); err != nil { return nil, err } + if err := sh.Input(probeLine(os)); err != nil { + return nil, err + } var buf bytes.Buffer @@ -90,6 +119,11 @@ func Run(ctx context.Context, sess *session.Session, os remote.OS, command strin } return res, err } + // The probe came back but the sentinel never did: the remote cut + // the command line short and ran what was left of it. + if reTrunc.Match(buf.Bytes()) { + return nil, errTruncated + } } } } diff --git a/internal/app/shell/run_test.go b/internal/app/shell/run_test.go new file mode 100644 index 0000000..0cc41b5 --- /dev/null +++ b/internal/app/shell/run_test.go @@ -0,0 +1,35 @@ +package shell + +import ( + "testing" + + "github.com/porech/dwshell/internal/remote" +) + +// The probe marker must be assembled by the remote shell, so that neither the +// PTY echoing the typed line back nor a command printing its own stdin can be +// mistaken for the marker. +func TestProbeMarkerCannotMatchTheTypedLine(t *testing.T) { + for _, os := range []remote.OS{remote.OSWindows, remote.OSLinux} { + typed := probeLine(os) + if reTrunc.MatchString(typed) { + t.Errorf("the typed probe line %q matches the marker regexp", typed) + } + } +} + +func TestProbeMarkerMatchesWhatTheShellPrints(t *testing.T) { + if !reTrunc.MatchString("__DWSH_TRUNC_0_END__\r\n") { + t.Error("the printed probe marker is not recognised") + } +} + +// A probe line has to survive the very truncation it detects, so it must stay +// far below any line limit a remote shell might impose. +func TestProbeLineIsShort(t *testing.T) { + for _, os := range []remote.OS{remote.OSWindows, remote.OSLinux} { + if n := len(probeLine(os)); n > 100 { + t.Errorf("probe line for %v is %d characters, too long to survive truncation", os, n) + } + } +}