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();