From 679cef61308603f995de4477ae6474af424e8b54 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 28 Sep 2026 01:22:27 +0200 Subject: [PATCH] Discard stdin a command does not read instead of failing on WSManFault 232 (#183) A Send answered with WSManFault 232 ("The pipe is being closed") means the command exited, or closed its stdin, before its input arrived. The client turned that race into a WinRMFaultException and lost the command's output and exit code: execute() and start() failed whenever stdin(...) fed a command that does not read it (every time on Windows Server 2008 R2, now and then on 2016 and 2022), and so did the CLI when input was piped into such a command. RemoteCommand.send now treats that fault like a broken pipe: it stops sending, discards the rest of the input, and the Receive loop goes on, so the caller gets the actual output and exit code. Any other fault still fails. Every stdin path goes through it: pre-supplied input with execute() and start(), and RemoteProcess.stdin(), whose flush() and close() stay silent for input the command does not read, because whether that input beats the command's exit varies from host to host. Co-Authored-By: Claude Opus 5.5 --- .../org/metricshub/winrm/CommandCursor.java | 4 + .../org/metricshub/winrm/CommandRequest.java | 4 +- .../org/metricshub/winrm/RemoteProcess.java | 6 + .../metricshub/winrm/light/WsmanClient.java | 46 +++++-- src/site/markdown/cli.md | 3 +- src/site/markdown/commands.md | 4 +- .../metricshub/winrm/CommandStdinTest.java | 116 +++++++++++++++++- 7 files changed, 167 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java b/src/main/java/org/metricshub/winrm/CommandCursor.java index b43063a..1bd8f74 100644 --- a/src/main/java/org/metricshub/winrm/CommandCursor.java +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java @@ -101,6 +101,10 @@ default Chunk poll(final long askMillis, final long maxWaitMillis) throws Timeou * connection: it alternates with {@link #next()}/{@link #poll(long)} on the caller's thread, * it never runs concurrently with them. *

+ * Input the command no longer reads (it exited, or closed its standard input, before the input + * arrived) is discarded, and so is any input sent after it: this is not a failure, and the + * output and exit code are still received. + *

* The default implementation throws {@link UnsupportedOperationException}: only executors that * support command input (such as the built-in lightweight backend) implement this method. * diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index d38a70a..0163214 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -310,7 +310,9 @@ public CommandRequest upload(final Path... files) { * Feed the given text to the command's standard input. The text is encoded with the same * charset used to decode the output (see {@link #charset(Charset)}) and delivered in full — * split into protocol-sized chunks when large — right after the command starts, ending with - * the end-of-input mark so the remote stdin reaches EOF. + * the end-of-input mark so the remote stdin reaches EOF. Input the command does not read (it + * exits without reading it, or before it arrives) is discarded, and the command's output and + * exit code are reported as usual. *

* Supplying input switches the remote stdin to pipe semantics * ({@code WINRS_CONSOLEMODE_STDIN=FALSE}): tools like {@code sort} or {@code findstr} consume diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index ea461d9..357449c 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -182,6 +182,12 @@ public BufferedReader stderr() { * {@link CommandRequest#stdin()} on the builder. Without it the remote stdin keeps the * historical console semantics, where writes are delivered but the end of input is not. *

+ * Input the command does not read (it exited, or closed its standard input, before the input + * arrived) is discarded: {@code flush()} and {@code close()} do not fail for it, and the output + * and exit code remain available. Unlike a {@link java.lang.Process} pipe, which throws an + * {@link IOException} in this case, the writer stays silent: whether the input beats the + * command's exit varies from host to host, and so would the failure. + *

* Failures while sending are reported through the unchecked * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy; writing after the end * of input or after the command completed throws {@link IllegalStateException}. diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index a539e08..006be1b 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -58,6 +58,10 @@ final class WsmanClient implements AutoCloseable { private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; private static final String FAULT_SHELL_NOT_FOUND = "2150858843"; + // A Send answered with this Windows error (ERROR_NO_DATA, "The pipe is being closed") found the + // command no longer reading its standard input: it exited, or closed its stdin, first. + private static final String FAULT_PIPE_CLOSING = "232"; + // The WSMan service clamps an OperationTimeout below 500 ms UP to 500 ms (MS-WSMV; measured on // Windows Server 2008 R2): a bounded Receive's "nothing yet" fault never arrives before this // floor, however early the header asks for it. @@ -617,6 +621,10 @@ final class Chunk { // caller bug and are rejected locally instead of drawing a server fault. private boolean stdinEnded; + // A Send drew FAULT_PIPE_CLOSING: the command no longer reads its input, so nothing more is + // sent — like writes to a broken pipe, the rest of the input is discarded. + private boolean stdinRefused; + private RemoteCommand(final String commandId, final long operationTimeoutMs, final boolean failOnQuietTimeout) { this.commandId = commandId; this.operationTimeoutMs = operationTimeoutMs; @@ -738,6 +746,10 @@ Chunk pollChunk(final long askMs, final long maxWaitMs) throws Exception { * A Send is an ordinary request under this handle's connection permit: it does not interleave * with the Receive loop, it alternates with it on the caller's thread — the same discipline * {@link java.lang.Process} pipes require. + *

+ * A Send refused because the command no longer reads its input (fault 232: it exited, or + * closed its stdin, before the input arrived) is not a failure: that input and any later one + * are discarded, and the output and exit code are still received. * * @param data the input bytes (may be empty, e.g. for a pure end-of-input Send) * @param end whether this is the last input the command will get @@ -754,24 +766,36 @@ void send(final byte[] data, final boolean end) throws Exception { return; } int offset = 0; - do { + while (!stdinRefused) { checkNotCancelled(); final int length = Math.min(Envelopes.MAX_STDIN_CHUNK, data.length - offset); final boolean last = offset + length >= data.length; final String base64 = length == 0 ? "" : Base64.getEncoder().encodeToString(Arrays.copyOfRange(data, offset, offset + length)); - // The same streaming timeout translation as the Receive loop: a server staying - // quiet for a whole inactivity timeout is the documented TimeoutException, not a - // raw socket failure or fault. - exchange( - Envelopes.send(url, shellId, commandId, base64, end && last, operationTimeoutMs), - "Send", - operationTimeoutMs, - failOnQuietTimeout - ); + try { + // The same streaming timeout translation as the Receive loop: a server staying + // quiet for a whole inactivity timeout is the documented TimeoutException, not a + // raw socket failure or fault. + exchange( + Envelopes.send(url, shellId, commandId, base64, end && last, operationTimeoutMs), + "Send", + operationTimeoutMs, + failOnQuietTimeout + ); + } catch (final WinRMFaultException e) { + if (!FAULT_PIPE_CLOSING.equals(e.getFaultCode())) { + throw e; + } + // A race the caller cannot win, which must not hide the command's output and exit + // code: they are still to be received. + stdinRefused = true; + } + if (last) { + break; + } offset += length; - } while (offset < data.length); + } if (end) { stdinEnded = true; } diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 3189367..6c32d4b 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -158,7 +158,8 @@ even when only the *output* is redirected (`... command hostname > result.txt`). undetectable case is a pipe whose producer has written nothing by the time the CLI starts: pass `-i`/`--stdin` to force forwarding there. The input is delivered in full before the output is read: piping a large input into a command that floods its output at the same time can deadlock -both sides (the classic pipe deadlock), exactly as with `java.lang.Process`. +both sides (the classic pipe deadlock), exactly as with `java.lang.Process`. Input the command +does not read, because it exits first, is discarded, as with a local pipe. ### `ls`, `stat`, `cat`, `get` diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 38718bb..dbbaeb7 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -165,7 +165,9 @@ Points to know: ## Standard input -Commands that read their standard input can be fed in two ways. +Commands that read their standard input can be fed in two ways. Either way, input the command +does not read (it exits without reading it, or before the input arrives) is discarded without +error, and the command's output and exit code are reported as usual. ### Pre-supplied input diff --git a/src/test/java/org/metricshub/winrm/CommandStdinTest.java b/src/test/java/org/metricshub/winrm/CommandStdinTest.java index 264ac7e..236d8eb 100644 --- a/src/test/java/org/metricshub/winrm/CommandStdinTest.java +++ b/src/test/java/org/metricshub/winrm/CommandStdinTest.java @@ -50,6 +50,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.metricshub.winrm.exceptions.WinRMClientException; +import org.metricshub.winrm.exceptions.WinRMFaultException; import org.metricshub.winrm.exceptions.WinRMTimeoutException; import org.metricshub.winrm.light.FakeWsmanServer; @@ -57,8 +58,8 @@ * End-to-end tests of command standard input (issue #136, phase 1) against * {@link FakeWsmanServer}: pre-supplied input on the builders ({@code stdin(...)}), the * process-style {@code RemoteProcess.stdin()} writer, the {@code ctrl_c} interrupt, the - * console-mode option on the wire, chunking, the {@code End} flag, and the cleanup discipline - * (early close, stdin after completion). + * console-mode option on the wire, chunking, the {@code End} flag, input the command does not + * read, and the cleanup discipline (early close, stdin after completion). */ class CommandStdinTest { @@ -69,6 +70,9 @@ class CommandStdinTest { private static final String SHELL_ID = "SHELL-1"; private static final String COMMAND_ID = "CMD-1"; + /** How a Send is answered when the command no longer reads its standard input. */ + private static final String PIPE_CLOSING = fault("232", "The pipe is being closed."); + private FakeWsmanServer server; @BeforeEach @@ -267,6 +271,114 @@ void aFailedStdinDeliveryIsNotMaskedByACleanupFailure(@TempDir final Path tempDi } } + @Test + void inputTheCommandDoesNotReadIsDiscardedByExecute() throws Exception { + // Three read buffers' worth of input for a command that exits without reading it: the very + // first Send finds its stdin closing (issue #183). + final char[] input = new char[150_000]; + java.util.Arrays.fill(input, 'x'); + + enqueueStartup(); + server + .enqueue(500, PIPE_CLOSING) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "HOST\r\n".getBytes(StandardCharsets.UTF_8)), + done(COMMAND_ID, 0) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + final CommandResult result = client.command("hostname").stdin(new String(input)).execute(); + + assertEquals(0, result.exitCode()); + assertEquals("HOST\r\n", result.stdout()); + } + + // The rest of the input was discarded, never sent. + assertEquals(1, server.stdinChunks().size()); + } + + @Test + void inputTheCommandDoesNotReadIsDiscardedByStart() throws Exception { + enqueueStartup(); + server + .enqueue(500, PIPE_CLOSING) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "HOST\r\n".getBytes(StandardCharsets.UTF_8)), + done(COMMAND_ID, 0) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("hostname").stdin("x\n").start()) { + assertEquals("HOST", process.stdout().readLine()); + assertEquals(0, process.waitFor()); + } + } + } + + @Test + void remoteProcessStdinDiscardsInputTheCommandDoesNotRead() throws Exception { + enqueueStartup(); + server + // the command exits before the flushed input arrives + .enqueue(500, PIPE_CLOSING) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", COMMAND_ID, "bye\r\n".getBytes(StandardCharsets.UTF_8)), done(COMMAND_ID, 1)) + ) + ) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("repl.exe").stdin().start()) { + final BufferedWriter stdin = process.stdin(); + // Neither the refused flush nor the later input fails: it is all discarded, and + // nothing more is sent. + writeAndFlush(stdin, "quit\n"); + final int requestsAfterRefusal = server.decryptedRequests().size(); + writeAndFlush(stdin, "ignored\n"); + stdin.close(); + assertEquals(requestsAfterRefusal, server.decryptedRequests().size()); + + assertEquals("bye", process.stdout().readLine()); + assertEquals(1, process.waitFor()); + } + } + } + + @Test + void anyOtherSendFaultStillFailsTheCommand() throws Exception { + enqueueStartup(); + server + .enqueue(500, fault("5", "Access is denied.")) + // the failed command is terminated + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + final WinRMFaultException failure = assertThrows( + WinRMFaultException.class, + () -> client.command("sort").stdin("x\n").execute() + ); + assertEquals("5", failure.getFaultCode()); + } + } + @Test void remoteProcessStdinSupportsAWriteFlushReadRoundTrip() throws Exception { enqueueStartup();