Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/java/org/metricshub/winrm/CommandCursor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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.
* <p>
* The default implementation throws {@link UnsupportedOperationException}: only executors that
* support command input (such as the built-in lightweight backend) implement this method.
*
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/org/metricshub/winrm/CommandRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* Supplying input switches the remote stdin to <b>pipe semantics</b>
* ({@code WINRS_CONSOLEMODE_STDIN=FALSE}): tools like {@code sort} or {@code findstr} consume
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/org/metricshub/winrm/RemoteProcess.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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.
* <p>
* 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}.
Expand Down
46 changes: 35 additions & 11 deletions src/main/java/org/metricshub/winrm/light/WsmanClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
* <p>
* 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
Expand All @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/site/markdown/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
4 changes: 3 additions & 1 deletion src/site/markdown/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
116 changes: 114 additions & 2 deletions src/test/java/org/metricshub/winrm/CommandStdinTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,16 @@
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;

/**
* 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 {

Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Loading