fix(xcecli): jobs submit with explicit command must not read stdin

bug-1980: ParseCmdArgs treated any non-terminal stdin as a potential job
script and io.ReadAll'ed it before considering the command args, so a
pipe that stayed open without writing data or EOF blocked submit
forever — no error, no RPC, no job. Found as a day-old hung process in
the user's process list (an esd-recover session's own submit probe).

bsub semantics the CLI claims to follow are the reverse: stdin is the
job script only when NO command is given on the command line. Gate the
stdin read on len(args) == 0; an explicit command wins and stdin is
never touched (also removes the ambiguity of stdin bytes being silently
reinterpreted as a config script next to an explicit command).

- TestParseCmdArgs_ExplicitCommandIgnoresHeldOpenStdin: held-open pipe
  + explicit args; RED pre-fix (blocked in io.ReadAll), GREEN post-fix.
- Stdin-script path (no args) verified live: script → submitted → DONE.
- Live: submit with held-open stdin went rc=124/no-job to instant
  submit → RUN → DONE on t1.
- xcecli go test ./... 11/11 packages ok.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-30 02:01:50 +00:00
parent 7520cca784
commit 01f61507ba
2 changed files with 44 additions and 2 deletions

View file

@ -102,9 +102,16 @@ func (js *JobSubmitter) ParseCmdArgs(cmd *cobra.Command, args []string) error {
} }
} }
// check if there is standard input redirection // check if there is standard input redirection -- but only when the
// command line carries no command of its own. A piped stdin is the job
// script (bsub < script semantics, parsed as #xce job submit lines by
// parseConfigFile below); an explicit command on the command line wins
// and stdin must not be touched at all: reading it would block forever
// on a pipe that stays open without writing data (bug-1980 -- a hung
// submit observed in live use), and any stdin bytes would be silently
// reinterpreted as a config script next to the explicit command.
fileInfo, err := os.Stdin.Stat() fileInfo, err := os.Stdin.Stat()
if err == nil && (fileInfo.Mode()&os.ModeCharDevice) == 0 { if err == nil && len(args) == 0 && (fileInfo.Mode()&os.ModeCharDevice) == 0 {
if tmpFileName, err := js.handleStdin(); err != nil { if tmpFileName, err := js.handleStdin(); err != nil {
errMsg := request.ExtractErrorMessage(err) errMsg := request.ExtractErrorMessage(err)
return fmt.Errorf("error reading from stdin: [%s]", errMsg) return fmt.Errorf("error reading from stdin: [%s]", errMsg)

View file

@ -376,3 +376,38 @@ func TestWaitForInteractiveURL_SuspendedJobKeepsWaiting(t *testing.T) {
t.Errorf("url = %q, want http://h:9000", url) t.Errorf("url = %q, want http://h:9000", url)
} }
} }
// bug-1980: `jobs submit <cmd...>` with a piped stdin used to block forever
// when the pipe stayed open with no data (heredoc quoting accidents, generic
// tooling that keeps a stdin pipe around): ParseCmdArgs treated ANY
// non-terminal stdin as a potential job script and io.ReadAll'ed it before
// looking at the command args. LSF (bsub) semantics the CLI claims to follow
// are the reverse: stdin is the job script only when no command is given on
// the command line -- an explicit command must win and never touch stdin.
func TestParseCmdArgs_ExplicitCommandIgnoresHeldOpenStdin(t *testing.T) {
pr, pw, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer pr.Close() //nolint:errcheck // read end: closed after Parse returns
t.Cleanup(func() { _ = pw.Close() })
// Keep the write end open for the duration of the call: the pre-fix code
// blocks in io.ReadAll here; the post-fix code must not read stdin at all.
oldStdin := os.Stdin
os.Stdin = pr
t.Cleanup(func() { os.Stdin = oldStdin })
js := NewJobSubmitter(nil)
cmd := NewSubmitJobsCmd(nil)
parseErr := js.ParseCmdArgs(cmd, []string{"sleep", "10"})
if parseErr != nil {
t.Fatalf("ParseCmdArgs with explicit command and held-open stdin: %v", parseErr)
}
if js.command != "sleep 10" {
t.Errorf("command = %q, want %q", js.command, "sleep 10")
}
if strings.Contains(js.configFile, "xcecli_stdin_") {
t.Errorf("stdin was consumed as a config file (%s) despite an explicit command", js.configFile)
}
}